Build Designer is a self-hosted web app at https://build.503mail.com for designing a house and garage on a real lot: the ground and its slope, site prep and dirt work, foundations and basements, floor plans, siding, roofs and floors, a realistic 3D walk-through, and plans that print at a true 1/4 inch = 1 foot. It's for family and friends, and anyone with a BlockCraft sign-in can use the same name and family code here. This post is the complete rebuild guide, and every block has a Copy button.
What it does
- Site & grading: lot size and setbacks, and ground heights typed in at the four corners or looked up from the street address (US Census geocoder for the address, then 81 points from the USGS elevation service). The site plan shows contour lines every foot. Each building gets a level pad that blends back into the natural ground over 10 feet, and the app works out cut, fill, the basement dig and dump-truck loads in cubic yards.
- Foundations: slab, crawlspace, full basement or walkout basement, in poured concrete, block or ICF. The finished floor height can follow the ground automatically. A walkout leaves the low side natural so the basement opens out.
- Floor plans: houses and 1, 2 or 3-car garages, up to 3 floors plus a basement. Tools: walls, rooms (which can add their own walls), doors, windows, sliding doors, garage doors and a tape measure, with a 1 inch, 6 inch or 1 foot snap. Everything can also be edited by keyboard: an object list, and a properties form where lengths are typed like
24or24' 6". A plain-language description is kept up to date for screen readers. - Finishes: vinyl lap, fiber cement lap, board and batten, cedar shake, brick, stone and stucco, plus a stone or brick wainscot. Gable, hip, shed or flat roofs in asphalt shingles or standing seam metal, with any pitch and overhang. Floors in oak, walnut, luxury vinyl plank, porcelain or ceramic tile, carpet or polished concrete. Pick any color for siding, roof, trim, doors and paint.
- 3D: the textures are drawn in the browser at their real sizes (4 inch vinyl courses, modular bricks, 5 inch oak planks). There's a sky, a sun you can move from 6 am to 8 pm with real shadows, and switches for the roof, the ground and each floor. Walk mode puts you inside at eye height (5 feet 6 inches). Save picture downloads a PNG.
- Elevations: front, back and side views drawn from the same model, with the finished grade line, the foundation below ground (dashed), and heights for the floor, wall top and roof top.
- Materials list: rough siding, brick, roof squares, concrete and flooring quantities.
- Print to scale: 1/4" = 1' by default (also 1/8", 1/2" and 1"), on letter or tabloid paper. Each building's floor plan gets its own sheet. Site plans use an engineering scale that fits one page (1" = 20' or 30'). Each sheet picks portrait or landscape, whichever needs fewer pages. A drawing too big for one page is split into pages with corner marks for taping. Every page has a 1 inch scale bar and a 1/4 inch test square, so you can check it was printed at "Actual size".
- Furniture (phase 2): about 50 items at real sizes: sofas, beds, tables and chairs, kitchen cabinets, counters and appliances, toilet, tub, shower and vanities, washer and dryer, a garage workbench and shelving, and a car, SUV and pickup for scale. Place them with the Furniture tool (anything with a back slides up against the nearest wall), or by keyboard from the side panel: pick an item, pick "against the back wall" (or front, left, right, middle), then "Add to selected room". The properties panel tells you how much space is left on each side, and R turns the selected item. The pieces show as plan symbols, print with the plans, and appear as 3D models.
- Lights, day and night: recessed cans, pendants, a ceiling fan with light, a chandelier, wall sconces, 4 foot garage shop lights and a floor lamp, each with its own brightness in lumens, light color (2700K to 5000K) and on switch. In 3D they're real lights: cans throw cones of light, and the nearest six cast shadows. Switch the 3D view to Night and the rooms are lit only by your fixtures, and the windows glow from the street.
- Attached buildings and L shapes: where a garage or wing touches the house, the shared wall is drawn once, not doubled, in the plan, the 3D view and the siding count. A door in that wall goes straight between the two. "Wing (L shape)" adds a wing to the back of the house, and "Attach to" snaps any building against a side of another.
- Door styles and colors: doors can be 6-panel, Craftsman (glass panes at the top), half glass, full glass, flush, double doors, or a barn door on an inside wall. Garage doors can be raised panel, carriage house with a window row, raised panel with windows, flush modern, or full-view glass with an aluminum frame. Each door has its own color. Every color picker also has a list of common real colors (white, almond, clay, sandstone, gray, charcoal, black, barn red, navy, forest green, bronze and more), with swatches beside it. The styles show in 3D (with real glass), in the elevations and in the materials list.
- Stairs (phase 3): straight, L-shaped and U-shaped stairs, sized to typical code (risers no more than 7 3/4 inches, 10 inch treads, 36 inches wide). They go from the basement or any floor up to the next one, cut their opening in the floor above, and show with UP and DN arrows on the plans and printed sheets. In 3D they have handrails, balusters and a guard rail round the opening, and walk mode climbs them.
- Basement egress windows: an egress window type with a galvanized window well (with gravel, and a ladder when it is deeper than 44 inches). The properties panel checks the window against typical egress rules: 5.7 square feet of clear opening, 20 inches wide, 24 inches high, and a sill no more than 44 inches above the floor.
- The lot: trees (oak, maple, birch, apple, dogwood, pine, spruce, arborvitae) in three sizes, a well, a septic tank and drain field, and utility trenches (electric, water, sewer, gas, internet) with their lengths. The site plan shows slope arrows with the grade in percent, and the app warns when the well is too close to the septic.
- Outside details: soffits, fascia, gutters (K-style or half round) and downspouts at the corners, and brick in running, stack or Flemish bond, with an optional herringbone accent band at the bottom of the walls or under the eaves.
- Inside finishes: wallpaper (stripes, damask, floral, gingham, grasscloth), inside wainscot (beadboard, raised panel, board and batten), tile backsplash behind counters and vanities (subway, hexagon, glass mosaic, herringbone), and flat, vaulted, tray or coffered ceilings.
- Find rooms from the walls: one button makes a room for every space the walls close in, with its square feet. Rooms already drawn keep their names and finishes.
- Two-story starter: a 36 by 28 foot two-story house with a full basement, stacked stairs, an egress window, an attached garage, furniture, lights, finishes, trees and trenches.
- Photo render: a sharper still picture of the 3D view (up to 4K), drawn at twice the size and scaled down, with crisper sun shadows. It opens in a dialog with a Download button and a text description.
- Rough cost estimate: per building plus site work, with every unit price editable (saved with the project) and a contingency percentage.
- Projects: saved on the server (autosave, undo/redo). Share a project with another user to view or edit. If two people save at once, the second gets asked which version to keep.
How it works
- The browser page (plain HTML and JavaScript modules, no build step) holds the whole design as one JSON object. Every length is stored in inches, so any print scale is exact.
- 2D plans are SVG drawn in real inches (
plan2d.js). The same drawing code makes the screen view and the printed sheets. - 3D uses Three.js 0.160, loaded from the jsDelivr CDN.
view3d.jsrebuilds the scene from the JSON after each change (well under a second for a house and garage). Walls are split around each opening, so windows and doors are real holes with frames, glass and grids. - The server (Flask on CT 310) handles sign-in, stores projects in SQLite, and passes the address and elevation lookups on to the Census and USGS services. An elevation lookup takes about 20 seconds.
- BlockCraft sign-in: every 5 minutes a timer on the Proxmox host copies BlockCraft's player list and codes from CT 127 (blockcraft) into CT 310, only readable by the app. Signing in with a BlockCraft name and its family's code (the home family code, a cousins/friends family's own code, or the admin code for "admin") creates a matching Build Designer user. Everyone else needs a one-time invite code.
Containers involved
| Container | Role | LAN IP |
|---|---|---|
| CT 108 (Caddy reverse proxy) | HTTPS for build.503mail.com, passes everything to CT 310 | x.x.0.122 |
| CT 310 (build-designer) | Flask app under gunicorn on port 8000, SQLite database | x.x.0.244 (DHCP) |
| CT 127 (blockcraft) | Source of the BlockCraft player list and codes (read only, never changed) | x.x.0.193 (DHCP) |
CT 310 is Debian 12, unprivileged, with nesting, 2 cores, 1 GB RAM and a 6 GB disk. The app uses well under 100 MB of RAM. DNS needs nothing new: *.503mail.com already points at home.
Where to run each command
- Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
- Inside CT 310 (build-designer): Proxmox web UI → 310 (build-designer) → Console, or
pct enter 310from the host shell. - Inside CT 108 (Caddy reverse proxy): Proxmox web UI → 108 (caddy) → Console.
Rebuild, step by step
1. Make a working folder on the host
Proxmox host shell:
mkdir -p /root/build
cd /root/build
2. Create every file
Paste each block into the Proxmox host shell, from inside /root/build. Each one writes one file.
01-create-ct.sh: creates CT 310 and installs Flask, gunicorn and SQLite.
cat > /root/build/01-create-ct.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host: creates the Build Designer container (build.503mail.com) and installs packages.
# Usage: bash 01-create-ct.sh 310
set -e
CTID=${1:?usage: 01-create-ct.sh <ctid>}
TEMPLATE=local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst
pct create "$CTID" "$TEMPLATE" \
--hostname build-designer --cores 2 --memory 1024 --swap 512 \
--rootfs local-lvm:6 --net0 "name=eth0,bridge=vmbr0,ip=dhcp" \
--unprivileged 1 --features nesting=1 --onboot 1 --ostype debian \
--timezone America/New_York
pct start "$CTID"
sleep 10
pct exec "$CTID" -- apt-get update
pct exec "$CTID" -- env DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
pct exec "$CTID" -- env DEBIAN_FRONTEND=noninteractive apt-get -y install python3-flask gunicorn curl sqlite3
pct exec "$CTID" -- useradd --system --home /opt/build --shell /usr/sbin/nologin buildapp
pct exec "$CTID" -- mkdir -p /opt/build/app/public /opt/build/data /opt/build/staging
pct exec "$CTID" -- chown -R buildapp:buildapp /opt/build
pct exec "$CTID" -- chmod 700 /opt/build/data
echo "CT $CTID IP:"
pct exec "$CTID" -- hostname -I
EOF
02-deploy-app.sh: pushes the app into CT 310, checks the Python compiles, swaps it in (the old copy is kept as /opt/build/app.old) and restarts.
cat > /root/build/02-deploy-app.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after deploy.ps1 copies BuildDesigner/ to /root/build/app.tar.
# Checks the Python compiles inside the container first, then swaps it in and restarts.
set -euo pipefail
CTID=310
pct push "$CTID" /root/build/app.tar /opt/build/staging/app.tar
pct exec "$CTID" -- rm -rf /opt/build/staging/new
pct exec "$CTID" -- mkdir -p /opt/build/staging/new
pct exec "$CTID" -- tar -xf /opt/build/staging/app.tar -C /opt/build/staging/new
pct exec "$CTID" -- python3 -m py_compile /opt/build/staging/new/app.py /opt/build/staging/new/pages.py
pct exec "$CTID" -- rm -rf /opt/build/app.old
pct exec "$CTID" -- mv /opt/build/app /opt/build/app.old
pct exec "$CTID" -- mv /opt/build/staging/new /opt/build/app
pct exec "$CTID" -- cp /opt/build/app/build.service /etc/systemd/system/build.service
pct exec "$CTID" -- chown -R buildapp:buildapp /opt/build
pct exec "$CTID" -- systemctl daemon-reload
pct exec "$CTID" -- systemctl enable build
pct exec "$CTID" -- systemctl restart build
sleep 3
pct exec "$CTID" -- curl -fsS http://127.0.0.1:8000/healthz
echo " <- Build Designer healthy"
EOF
edge-add.sh: runs inside CT 108: adds the build.503mail.com site block, validates the config, and restores the backup if validation fails.
cat > /root/build/edge-add.sh <<'EOF_OUTER1'
#!/bin/bash
# Runs INSIDE CT 108 (edge Caddy): adds build.503mail.com -> CT 310 (once), validates, reloads.
# bash edge-add.sh <CT 310 LAN IP>
set -e
IP="$1"
[ -n "$IP" ] || { echo "usage: $0 <CT 310 LAN IP>"; exit 1; }
CF=/etc/caddy/Caddyfile
if grep -q '^build.503mail.com' "$CF"; then echo "already there"; exit 0; fi
cp "$CF" "$CF.bak-build"
cat >> "$CF" <<EOF
# Build Designer - house + garage designer (CT 310, separate project from the creator platform).
build.503mail.com {
request_body {
max_size 5MB
}
header {
X-Content-Type-Options nosniff
Referrer-Policy same-origin
X-Frame-Options SAMEORIGIN
}
reverse_proxy $IP:8000
}
EOF
if caddy validate --config "$CF" --adapter caddyfile; then
systemctl reload caddy
echo EDGE-OK
else
cp "$CF.bak-build" "$CF"
echo "EDGE-FAILED, restored backup"
exit 1
fi
EOF_OUTER1
sync-blockcraft-login.sh: copies BlockCraft's player list from CT 127 into CT 310 when it has changed.
cat > /root/build/sync-blockcraft-login.sh <<'EOF'
#!/bin/bash
# Runs on the Proxmox host (systemd timer build-bc-sync, every 5 min): copies the BlockCraft
# family list + codes from CT 127 into CT 310 so BlockCraft players can sign in to build.503mail.com.
# Only copies when the file changed. Readable by the buildapp user only.
set -e
SRC=/opt/blockcraft/config.json
TMP=/root/build/.bc-config.json
pct pull 127 "$SRC" "$TMP"
NEW=$(sha256sum "$TMP" | cut -d' ' -f1)
OLD=$(cat /root/build/.bc-config.sha 2>/dev/null || true)
if [ "$NEW" != "$OLD" ]; then
pct push 310 "$TMP" /opt/build/data/blockcraft.json --perms 600
pct exec 310 -- chown buildapp:buildapp /opt/build/data/blockcraft.json
echo "$NEW" > /root/build/.bc-config.sha
echo "BlockCraft login list copied to CT 310"
fi
unlink "$TMP"
EOF
install-bc-sync.sh: installs the 5-minute timer that runs the sync, then runs it once.
cat > /root/build/install-bc-sync.sh <<'EOF_OUTER1'
#!/bin/bash
# Run once on the Proxmox host: installs the build-bc-sync timer (every 5 minutes) and runs it now.
set -e
chmod 700 /root/build/sync-blockcraft-login.sh
cat > /etc/systemd/system/build-bc-sync.service <<'EOF'
[Unit]
Description=Copy BlockCraft sign-in list from CT 127 to Build Designer CT 310
[Service]
Type=oneshot
ExecStart=/bin/bash /root/build/sync-blockcraft-login.sh
EOF
cat > /etc/systemd/system/build-bc-sync.timer <<'EOF'
[Unit]
Description=Copy BlockCraft sign-in list to CT 310 every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now build-bc-sync.timer
systemctl start build-bc-sync.service
systemctl status build-bc-sync.service --no-pager | tail -3
pct exec 310 -- ls -l /opt/build/data/blockcraft.json
EOF_OUTER1
make-invite.sh: prints a one-time invite code and sign-up link, for people who don't play BlockCraft.
cat > /root/build/make-invite.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host: prints a new one-time Build Designer invite code and sign-up link.
pct exec 310 -- runuser -u buildapp -- env BUILD_DATA=/opt/build/data python3 /opt/build/app/app.py invite
EOF
test-instance.sh: starts a throwaway test copy on port 8001 inside CT 310, with its own empty database, a fake BlockCraft list (Testkid / test-family-123, plus a cousin family: Testcousin / test-cousin-789) and a test invite. Stop it with pct exec 310 -- systemctl stop buildtest.
cat > /root/build/test-instance.sh <<'EOF_OUTER1'
#!/bin/bash
# Run on the Proxmox host: starts a THROWAWAY test copy of Build Designer on CT 310 port 8001
# (own database in /tmp/buildtest, plain-http cookies) and prints a test invite code.
# Optional argument: app folder to run (default the live /opt/build/app; 03-deploy-test.sh passes /opt/build/testapp).
# Stop it with: pct exec 310 -- systemctl stop buildtest
CTID=310
APP="${1:-/opt/build/app}"
pct exec "$CTID" -- systemctl stop buildtest 2>/dev/null
pct exec "$CTID" -- rm -rf /tmp/buildtest
pct exec "$CTID" -- install -d -o buildapp -g buildapp /tmp/buildtest
# fake BlockCraft list for testing the BlockCraft sign-in (NOT the real family code)
# Testinvited has an own password (test-own-pw-1), stored the way BlockCraft's sign-up stores it
python3 - > /root/build/.bc-test.json <<'EOF'
import base64, hashlib, json, os
salt = os.urandom(16)
h = "scrypt$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(hashlib.scrypt(b"test-own-pw-1", salt=salt, n=16384, r=8, p=1, dklen=32)).decode()
print(json.dumps({"names": ["Testkid", "Testcousin", "Testinvited", "admin"], "code": "test-family-123", "adminCode": "test-admin-456",
"families": [{"name": "Test cousins", "code": "test-cousin-789", "players": ["Testcousin", "Testinvited"]}],
"pw": {"testinvited": h}}))
EOF
pct push "$CTID" /root/build/.bc-test.json /tmp/buildtest/blockcraft.json --perms 600
pct exec "$CTID" -- chown buildapp:buildapp /tmp/buildtest/blockcraft.json
pct exec "$CTID" -- systemd-run --unit buildtest --uid buildapp --gid buildapp --working-directory "$APP" \
--setenv BUILD_DATA=/tmp/buildtest --setenv BUILD_INSECURE=1 --setenv BUILD_PUBLIC="$APP/public" \
/usr/bin/gunicorn --workers 1 --threads 4 --bind 0.0.0.0:8001 app:app
sleep 3
pct exec "$CTID" -- runuser -u buildapp -- env BUILD_DATA=/tmp/buildtest BUILD_PUBLIC_URL=http://localhost:8765 python3 "$APP/app.py" invite
EOF_OUTER1
test-bc-login.sh: run after test-instance.sh. It signs in to the test copy six times (home kid, cousin and admin, each with the right and the wrong code). Every line should start with ok. Cousins and friends get their own family code on the BlockCraft Players page (login needed).
cat > /root/build/test-bc-login.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after test-instance.sh: signs in to the TEST copy (CT 310 :8001) with the fake
# BlockCraft list - home kid with the home code, cousin with their own family code, an invited player
# with their own password (not the family code), and the wrong mixes.
URL=http://$(pct exec 310 -- hostname -I | awk '{print $1}'):8001
FAIL=0
try() { # name code expect(ok|no)
J=$(mktemp)
TOKEN=$(curl -s -c "$J" "$URL/login" | grep -o 'name="csrf" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"$//')
LOC=$(curl -s -b "$J" -o /dev/null -w '%{redirect_url}' --data-urlencode "csrf=$TOKEN" \
--data-urlencode "username=$1" --data-urlencode "password=$2" "$URL/login")
rm -f "$J"
if [ -n "$LOC" ]; then GOT=ok; else GOT=no; fi
if [ "$GOT" = "$3" ]; then echo "ok $1 with $2 -> $GOT"; else echo "FAIL $1 with $2 -> $GOT (wanted $3)"; FAIL=1; fi
}
try Testkid test-family-123 ok
try Testkid test-cousin-789 no
try Testcousin test-cousin-789 ok
try Testcousin test-family-123 no
try Testinvited test-own-pw-1 ok
try Testinvited test-cousin-789 no
try admin test-admin-456 ok
try admin test-family-123 no
echo "test exit code: $FAIL"
EOF
03-deploy-test.sh: puts a new version into /opt/build/testapp and starts the test copy from it, so changes can be tried before the live app is touched.
cat > /root/build/03-deploy-test.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host after `deploy.ps1 -Test` copies the bundle to /root/build/app-test.tar.
# Unpacks it to /opt/build/testapp on CT 310 (the live app in /opt/build/app is untouched) and
# restarts the throwaway test copy on port 8001 from there.
set -euo pipefail
CTID=310
pct push "$CTID" /root/build/app-test.tar /opt/build/staging/app-test.tar
pct exec "$CTID" -- rm -rf /opt/build/testapp
pct exec "$CTID" -- mkdir -p /opt/build/testapp
pct exec "$CTID" -- tar -xf /opt/build/staging/app-test.tar -C /opt/build/testapp
pct exec "$CTID" -- python3 -m py_compile /opt/build/testapp/app.py /opt/build/testapp/pages.py
pct exec "$CTID" -- chown -R buildapp:buildapp /opt/build/testapp
bash /root/build/test-instance.sh /opt/build/testapp
EOF
build.service: the systemd service for the app inside CT 310.
cat > /root/build/build.service <<'EOF'
[Unit]
Description=Build Designer (build.503mail.com)
After=network-online.target
Wants=network-online.target
[Service]
User=buildapp
Group=buildapp
WorkingDirectory=/opt/build/app
Environment=BUILD_DATA=/opt/build/data
Environment=BUILD_PUBLIC=/opt/build/app/public
ExecStart=/usr/bin/gunicorn --workers 1 --threads 8 --bind 0.0.0.0:8000 --timeout 120 app:app
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
app.py: the server: sign-in (local accounts and BlockCraft), projects and sharing, address and elevation lookups, and the command line (invite, users, reset-password).
cat > /root/build/app.py <<'EOF'
"""Build Designer - house + garage design app for friends and family (build.503mail.com).
Serves the static designer (public/) to signed-in users and stores projects as JSON.
All lengths in a project are inches. Invite-only sign-up.
Runs in CT 310 under gunicorn. Command line (as the buildapp user):
python3 app.py invite create a one-time invite code
python3 app.py users list users
python3 app.py reset-password U set a new password for user U
"""
import base64
import datetime as dt
import hashlib
import json
import os
import re
import secrets
import sqlite3
import sys
import threading
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from functools import wraps
from flask import (Flask, abort, g, jsonify, redirect, render_template_string, request,
send_from_directory, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from pages import LOGIN, REGISTER
DATA_DIR = os.environ.get("BUILD_DATA", "/opt/build/data")
PUBLIC_DIR = os.environ.get("BUILD_PUBLIC", os.path.join(os.path.dirname(os.path.abspath(__file__)), "public"))
DB_PATH = os.path.join(DATA_DIR, "build.db")
PUBLIC_URL = os.environ.get("BUILD_PUBLIC_URL", "https://build.503mail.com")
MAX_PROJECT_BYTES = 4 * 1024 * 1024
def _read_or_make_key(name, maker):
path = os.path.join(DATA_DIR, name)
if not os.path.exists(path):
os.makedirs(DATA_DIR, exist_ok=True)
with open(path, "wb") as f:
f.write(maker())
os.chmod(path, 0o600)
with open(path, "rb") as f:
return f.read().strip()
app = Flask(__name__, static_folder=None)
app.secret_key = _read_or_make_key("session.key", lambda: secrets.token_hex(32).encode())
app.config.update(SESSION_COOKIE_SECURE=os.environ.get("BUILD_INSECURE") != "1",
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=dt.timedelta(days=30),
MAX_CONTENT_LENGTH=MAX_PROJECT_BYTES + 65536)
# ---------------------------------------------------------------- database
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY, username TEXT UNIQUE NOT NULL, pw_hash TEXT NOT NULL, created INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY, created INTEGER NOT NULL, used_by INTEGER, used_at INTEGER);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY, owner INTEGER NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL,
created INTEGER NOT NULL, updated INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS shares (
project_id INTEGER NOT NULL, user_id INTEGER NOT NULL, role TEXT NOT NULL DEFAULT 'view',
PRIMARY KEY(project_id, user_id));
"""
def connect():
conn = sqlite3.connect(DB_PATH, timeout=15)
conn.row_factory = sqlite3.Row
return conn
def db():
if "db" not in g:
g.db = connect()
return g.db
@app.teardown_appcontext
def _close_db(exc):
conn = g.pop("db", None)
if conn is not None:
conn.close()
def init_db():
os.makedirs(DATA_DIR, exist_ok=True)
conn = connect()
conn.executescript(SCHEMA)
cols = [r[1] for r in conn.execute("PRAGMA table_info(users)")]
if "source" not in cols:
conn.execute("ALTER TABLE users ADD COLUMN source TEXT NOT NULL DEFAULT 'local'")
conn.execute("PRAGMA journal_mode=WAL")
conn.commit()
conn.close()
init_db()
# ---------------------------------------------------------------- auth / csrf
def current_user():
uid = session.get("uid")
if not uid:
return None
if "user" not in g:
g.user = db().execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
return g.user
def login_required(f):
@wraps(f)
def wrapper(*a, **kw):
if not current_user():
if request.path.startswith("/api/"):
return jsonify(error="Signed out. Reload the page and sign in."), 401
return redirect(url_for("login", next=request.path))
return f(*a, **kw)
return wrapper
def csrf_token():
if "csrf" not in session:
session["csrf"] = secrets.token_hex(16)
return session["csrf"]
@app.before_request
def _check_csrf():
if request.method in ("POST", "PUT", "DELETE"):
sent = request.headers.get("X-CSRF") or request.form.get("csrf", "")
if not secrets.compare_digest(sent, session.get("csrf", "x")):
if request.path.startswith("/api/"):
return jsonify(error="Page expired. Reload and try again."), 400
abort(400, "Form expired. Go back, reload the page and try again.")
_login_fails = {}
_login_lock = threading.Lock()
def _client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
def _too_many_fails(ip):
now = time.time()
with _login_lock:
recent = [t for t in _login_fails.get(ip, []) if now - t < 900]
_login_fails[ip] = recent
return len(recent) >= 10
def _note_fail(ip):
with _login_lock:
_login_fails.setdefault(ip, []).append(time.time())
def page(tpl, **kw):
return render_template_string(tpl, csrf=csrf_token(), **kw)
# BlockCraft players sign in with their BlockCraft name + family code (admin: its own code).
# The Proxmox host copies CT 127's /opt/blockcraft/config.json here every 5 minutes
# (proxmox/sync-blockcraft-login.sh), so a code change in BlockCraft reaches this app too.
BC_CONFIG = os.path.join(DATA_DIR, "blockcraft.json")
def blockcraft_names():
try:
with open(BC_CONFIG, encoding="utf-8") as f:
return [str(n).strip() for n in json.load(f).get("names", []) if str(n).strip()]
except (OSError, ValueError):
return []
def blockcraft_check(name, code):
"""Returns the BlockCraft name (as spelled on the family list) if name + code are right."""
try:
with open(BC_CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
except (OSError, ValueError):
return None
names = {str(n).strip().lower(): str(n).strip() for n in cfg.get("names", []) if str(n).strip()}
key = name.strip().lower()
if key not in names:
return None
# cousins/friends families (BlockCraft Players page) have their own code
need = cfg.get("code")
for fam in cfg.get("families") or []:
if key in (str(p).strip().lower() for p in fam.get("players", [])):
need = fam.get("code")
break
if key == "admin" and cfg.get("adminCode"):
need = cfg.get("adminCode")
elif (cfg.get("pw") or {}).get(key):
# invited players chose their own password on games.503mail.com/login: 'scrypt$salt$hash' (Node scrypt defaults)
return names[key] if _scrypt_ok(code, cfg["pw"][key]) else None
if not need or not secrets.compare_digest(str(code).encode(), str(need).encode()):
return None
return names[key]
def _scrypt_ok(pw, stored):
try:
kind, salt, want = str(stored).split("$")
want = base64.b64decode(want)
got = hashlib.scrypt(str(pw).encode(), salt=base64.b64decode(salt), n=16384, r=8, p=1, dklen=len(want))
except (ValueError, TypeError):
return False
return kind == "scrypt" and secrets.compare_digest(got, want)
def blockcraft_user(display):
"""Finds or creates the Build Designer user for a BlockCraft name."""
uname = display.lower()
row = db().execute("SELECT * FROM users WHERE username=?", (uname,)).fetchone()
if row:
return row if row["source"] == "blockcraft" else None
cur = db().execute("INSERT INTO users (username, pw_hash, created, source) VALUES (?,?,?,?)",
(uname, "!blockcraft", int(time.time()), "blockcraft"))
db().commit()
return db().execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
ip = _client_ip()
if _too_many_fails(ip):
error = "Too many tries. Wait 15 minutes and try again."
else:
name = request.form.get("username", "").strip().lower()
pw = request.form.get("password", "")
row = db().execute("SELECT * FROM users WHERE username=? AND source='local'", (name,)).fetchone()
if not (row and check_password_hash(row["pw_hash"], pw)):
bc = blockcraft_check(name, pw)
row = blockcraft_user(bc) if bc else None
if bc and not row:
error = "That name is already used by a Build Designer account."
if row and (row["source"] == "blockcraft" or check_password_hash(row["pw_hash"], pw)):
session.clear()
session.permanent = True
session["uid"] = row["id"]
nxt = request.args.get("next", "")
return redirect(nxt if nxt.startswith("/") and not nxt.startswith("//") else "/")
_note_fail(ip)
error = error or "Wrong name or password."
return page(LOGIN, error=error)
@app.route("/register", methods=["GET", "POST"])
def register():
error = None
if request.method == "POST":
code = request.form.get("invite", "").strip().upper()
name = request.form.get("username", "").strip().lower()
pw = request.form.get("password", "")
inv = db().execute("SELECT * FROM invites WHERE code=? AND used_by IS NULL", (code,)).fetchone()
if not inv:
error = "That invite code is not valid or was already used."
elif not re.fullmatch(r"[a-z0-9._-]{3,30}", name):
error = "Username: 3 to 30 letters, numbers, dots, dashes or underscores."
elif len(pw) < 8:
error = "Password must be at least 8 characters."
elif pw != request.form.get("password2", ""):
error = "The two passwords do not match."
elif db().execute("SELECT 1 FROM users WHERE username=?", (name,)).fetchone():
error = "That username is taken."
elif name in (n.lower() for n in blockcraft_names()):
error = "That is a BlockCraft player's name. Sign in with it and the BlockCraft family code instead."
else:
cur = db().execute("INSERT INTO users (username, pw_hash, created) VALUES (?,?,?)",
(name, generate_password_hash(pw), int(time.time())))
db().execute("UPDATE invites SET used_by=?, used_at=? WHERE code=?",
(cur.lastrowid, int(time.time()), code))
db().commit()
session.clear()
session.permanent = True
session["uid"] = cur.lastrowid
return redirect("/")
return page(REGISTER, error=error, invite=request.args.get("invite", ""))
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- static app
@app.route("/")
@login_required
def index():
with open(os.path.join(PUBLIC_DIR, "index.html"), encoding="utf-8") as f:
html = f.read()
u = current_user()
html = html.replace("__CSRF__", csrf_token()).replace("__USER__", u["username"])
return html, 200, {"Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store"}
@app.route("/static/<path:name>")
@login_required
def static_file(name):
resp = send_from_directory(PUBLIC_DIR, name)
resp.headers["Cache-Control"] = "no-cache"
return resp
@app.route("/healthz")
def healthz():
db().execute("SELECT 1").fetchone()
return "ok"
# ---------------------------------------------------------------- projects API
def project_role(pid, uid):
row = db().execute("SELECT owner FROM projects WHERE id=?", (pid,)).fetchone()
if not row:
return None
if row["owner"] == uid:
return "owner"
sh = db().execute("SELECT role FROM shares WHERE project_id=? AND user_id=?", (pid, uid)).fetchone()
return sh["role"] if sh else None
@app.route("/api/projects", methods=["GET"])
@login_required
def list_projects():
uid = current_user()["id"]
rows = db().execute("""
SELECT p.id, p.name, p.updated, u.username AS owner_name,
CASE WHEN p.owner=? THEN 'owner' ELSE s.role END AS role
FROM projects p JOIN users u ON u.id=p.owner
LEFT JOIN shares s ON s.project_id=p.id AND s.user_id=?
WHERE p.owner=? OR s.user_id=? ORDER BY p.updated DESC""", (uid, uid, uid, uid)).fetchall()
return jsonify(projects=[dict(r) for r in rows])
def _clean_name(n):
n = re.sub(r"\s+", " ", str(n or "")).strip()[:80]
return n or "Untitled project"
@app.route("/api/projects", methods=["POST"])
@login_required
def create_project():
body = request.get_json(silent=True) or {}
data = body.get("data")
if not isinstance(data, dict):
return jsonify(error="Missing project data."), 400
text = json.dumps(data, separators=(",", ":"))
if len(text) > MAX_PROJECT_BYTES:
return jsonify(error="Project is too large."), 400
now = int(time.time())
cur = db().execute("INSERT INTO projects (owner, name, data, created, updated) VALUES (?,?,?,?,?)",
(current_user()["id"], _clean_name(body.get("name")), text, now, now))
db().commit()
return jsonify(id=cur.lastrowid, updated=now)
@app.route("/api/projects/<int:pid>", methods=["GET"])
@login_required
def get_project(pid):
role = project_role(pid, current_user()["id"])
if not role:
return jsonify(error="Project not found."), 404
row = db().execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone()
shares = []
if role == "owner":
shares = [dict(r) for r in db().execute(
"SELECT u.username, s.role FROM shares s JOIN users u ON u.id=s.user_id WHERE s.project_id=?",
(pid,)).fetchall()]
return jsonify(id=pid, name=row["name"], data=json.loads(row["data"]), updated=row["updated"],
role=role, shares=shares)
@app.route("/api/projects/<int:pid>", methods=["PUT"])
@login_required
def save_project(pid):
role = project_role(pid, current_user()["id"])
if role not in ("owner", "edit"):
return jsonify(error="You can view this project but not change it."), 403
body = request.get_json(silent=True) or {}
data = body.get("data")
if not isinstance(data, dict):
return jsonify(error="Missing project data."), 400
text = json.dumps(data, separators=(",", ":"))
if len(text) > MAX_PROJECT_BYTES:
return jsonify(error="Project is too large."), 400
base = body.get("base_updated")
row = db().execute("SELECT updated FROM projects WHERE id=?", (pid,)).fetchone()
if base is not None and row["updated"] > int(base) and not body.get("force"):
return jsonify(error="Someone else saved this project after you opened it.", conflict=True), 409
now = max(int(time.time()), row["updated"] + 1)
db().execute("UPDATE projects SET name=?, data=?, updated=? WHERE id=?",
(_clean_name(body.get("name")), text, now, pid))
db().commit()
return jsonify(ok=True, updated=now)
@app.route("/api/projects/<int:pid>", methods=["DELETE"])
@login_required
def delete_project(pid):
if project_role(pid, current_user()["id"]) != "owner":
return jsonify(error="Only the owner can delete a project."), 403
db().execute("DELETE FROM shares WHERE project_id=?", (pid,))
db().execute("DELETE FROM projects WHERE id=?", (pid,))
db().commit()
return jsonify(ok=True)
@app.route("/api/projects/<int:pid>/share", methods=["POST"])
@login_required
def share_project(pid):
if project_role(pid, current_user()["id"]) != "owner":
return jsonify(error="Only the owner can share a project."), 403
body = request.get_json(silent=True) or {}
name = str(body.get("username", "")).strip().lower()
role = body.get("role", "view")
u = db().execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
if not u:
return jsonify(error=f"No user named {name}."), 404
if u["id"] == current_user()["id"]:
return jsonify(error="That is you."), 400
if role == "remove":
db().execute("DELETE FROM shares WHERE project_id=? AND user_id=?", (pid, u["id"]))
elif role in ("view", "edit"):
db().execute("INSERT OR REPLACE INTO shares (project_id, user_id, role) VALUES (?,?,?)",
(pid, u["id"], role))
else:
return jsonify(error="Role must be view, edit or remove."), 400
db().commit()
return jsonify(ok=True)
# ---------------------------------------------------------------- site / elevation lookup
def _get_json(url, timeout=20):
req = urllib.request.Request(url, headers={"User-Agent": "build.503mail.com designer"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
_elev_cache = {}
def elevation_ft(lat, lon):
key = (round(lat, 6), round(lon, 6))
if key in _elev_cache:
return _elev_cache[key]
q = urllib.parse.urlencode({"x": lon, "y": lat, "wkid": 4326, "units": "Feet", "includeDate": "false"})
for attempt in range(3):
try:
v = float(_get_json("https://epqs.nationalmap.gov/v1/json?" + q)["value"])
if v < -1000:
raise ValueError("no data")
_elev_cache[key] = v
return v
except Exception:
time.sleep(0.5 + attempt)
return None
@app.route("/api/geocode")
@login_required
def geocode():
addr = request.args.get("address", "").strip()[:200]
if len(addr) < 5:
return jsonify(error="Type a full street address."), 400
q = urllib.parse.urlencode({"address": addr, "benchmark": "Public_AR_Current", "format": "json"})
try:
res = _get_json("https://geocoding.geo.census.gov/geocoder/locations/onelineaddress?" + q)
m = res["result"]["addressMatches"]
except Exception:
return jsonify(error="The address lookup service did not answer. Try again in a minute."), 502
if not m:
return jsonify(error="Address not found. Check the spelling, or type the elevations in by hand."), 404
c = m[0]["coordinates"]
return jsonify(lat=c["y"], lon=c["x"], match=m[0]["matchedAddress"])
@app.route("/api/elevation-grid", methods=["POST"])
@login_required
def elevation_grid():
"""Samples USGS ground elevation on an n x n grid over the lot.
Lot is width_ft (along the street, x = east) by depth_ft (y = north from the street),
with the lat/lon point at the lot centre. Returns feet."""
b = request.get_json(silent=True) or {}
try:
lat, lon = float(b["lat"]), float(b["lon"])
w, d = float(b["width_ft"]), float(b["depth_ft"])
n = max(3, min(11, int(b.get("n", 9))))
except (KeyError, TypeError, ValueError):
return jsonify(error="Need lat, lon, width_ft, depth_ft."), 400
if not (10 <= w <= 3000 and 10 <= d <= 3000):
return jsonify(error="Lot size must be 10 to 3000 feet."), 400
import math
ft_lat = 364000.0
ft_lon = 364000.0 * math.cos(math.radians(lat))
pts = []
for j in range(n):
for i in range(n):
x = (i / (n - 1) - 0.5) * w
y = (j / (n - 1) - 0.5) * d
pts.append((lat + y / ft_lat, lon + x / ft_lon))
with ThreadPoolExecutor(max_workers=8) as ex:
vals = list(ex.map(lambda p: elevation_ft(*p), pts))
if sum(v is None for v in vals) > len(vals) // 4:
return jsonify(error="The USGS elevation service did not answer for enough points. Try again."), 502
good = [v for v in vals if v is not None]
avg = sum(good) / len(good)
vals = [avg if v is None else v for v in vals]
return jsonify(n=n, feet=vals)
# ---------------------------------------------------------------- command line
def cli(argv):
conn = connect()
if argv[:1] == ["invite"]:
code = "-".join(secrets.token_hex(2).upper() for _ in range(3))
conn.execute("INSERT INTO invites (code, created) VALUES (?,?)", (code, int(time.time())))
conn.commit()
print(f"Invite code: {code}")
print(f"Sign-up link: {PUBLIC_URL}/register?invite={code}")
elif argv[:1] == ["users"]:
for r in conn.execute("SELECT id, username, created FROM users ORDER BY id"):
print(r["id"], r["username"], dt.datetime.fromtimestamp(r["created"]).isoformat(" ", "minutes"))
elif argv[:1] == ["reset-password"] and len(argv) == 2:
import getpass
pw = getpass.getpass("New password: ")
if len(pw) < 8:
sys.exit("Password must be at least 8 characters.")
n = conn.execute("UPDATE users SET pw_hash=? WHERE username=?",
(generate_password_hash(pw), argv[1].lower())).rowcount
conn.commit()
print("Password changed." if n else "No such user.")
else:
print(__doc__)
if __name__ == "__main__":
cli(sys.argv[1:])
EOF
pages.py: the sign-in and sign-up pages.
cat > /root/build/pages.py <<'EOF'
"""Sign-in and sign-up pages (Jinja strings)."""
_HEAD = """<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} - Build Designer</title>
<style>
body{margin:0;background:#16181b;color:#e8e8e8;font:17px/1.5 system-ui,sans-serif}
main{max-width:420px;margin:48px auto;padding:0 16px}
h1{font-size:1.6rem;margin:0 0 4px}
p.sub{color:#b8b8b8;margin:0 0 24px}
form{border:1px solid #6a6a6a;border-radius:10px;padding:20px;background:#1e2024}
label{display:block;margin:12px 0 4px;font-weight:600}
input{width:100%;box-sizing:border-box;padding:10px;font-size:17px;border-radius:6px;
border:1px solid #6a6a6a;background:#111;color:#fff}
button{margin-top:18px;width:100%;padding:12px;font-size:17px;font-weight:700;border:0;
border-radius:6px;background:#d98a3a;color:#111;cursor:pointer}
.err{background:#5a1f1f;border:1px solid #c05050;padding:10px;border-radius:6px}
a{color:#f0b070}
</style></head><body><main>
<h1>Build Designer</h1><p class="sub">Design houses and garages to scale.</p>
"""
LOGIN = _HEAD.replace("{{ title }}", "Sign in") + """
{% if error %}<p class="err" role="alert">{{ error }}</p>{% endif %}
<form method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label for="u">Username or BlockCraft name</label><input id="u" name="username" autocomplete="username" required autofocus>
<label for="p">Password or BlockCraft family code</label><input id="p" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
<p>BlockCraft players: use the same name and family code you use in BlockCraft.</p>
<p>Have an invite code? <a href="/register">Create an account</a>.</p>
</main></body></html>"""
REGISTER = _HEAD.replace("{{ title }}", "Create account") + """
{% if error %}<p class="err" role="alert">{{ error }}</p>{% endif %}
<form method="post">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label for="i">Invite code</label><input id="i" name="invite" value="{{ invite }}" required>
<label for="u">Username</label><input id="u" name="username" autocomplete="username" required>
<label for="p">Password (8 or more characters)</label><input id="p" name="password" type="password" autocomplete="new-password" required>
<label for="p2">Password again</label><input id="p2" name="password2" type="password" autocomplete="new-password" required>
<button type="submit">Create account</button>
</form>
<p>Already have an account? <a href="/login">Sign in</a>.</p>
</main></body></html>"""
EOF
index.html: the page shell: tabs, toolbars, side panels and the projects dialog.
cat > /root/build/index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf" content="__CSRF__">
<title>Build Designer</title>
<link rel="stylesheet" href="/static/style.css">
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
} }
</script>
</head>
<body>
<header class="top">
<h1>Build Designer</h1>
<label class="pname"><span class="sr">Project name</span><input id="projName" aria-label="Project name"></label>
<button id="btnProjects" aria-haspopup="dialog">Projects</button>
<button id="btnSave">Save</button>
<span id="saveStatus" class="muted"></span>
<button id="btnUndo" title="Undo (Ctrl+Z)">Undo</button>
<button id="btnRedo" title="Redo (Ctrl+Y)">Redo</button>
<span class="grow"></span>
<span class="muted">Signed in as __USER__</span>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="__CSRF__"><button>Sign out</button></form>
</header>
<nav class="tabs" role="tablist" aria-label="Views">
<button role="tab" data-tab="plan" aria-selected="true">Floor plans</button>
<button role="tab" data-tab="three" aria-selected="false">3D</button>
<button role="tab" data-tab="elev" aria-selected="false">Elevations</button>
<button role="tab" data-tab="site" aria-selected="false">Site & grading</button>
<button role="tab" data-tab="mat" aria-selected="false">Materials & cost</button>
<button role="tab" data-tab="print" aria-selected="false">Print</button>
</nav>
<div id="live" class="sr" aria-live="polite"></div>
<main>
<!-- ======================= PLAN -->
<section id="tab-plan" class="tab on" role="tabpanel" aria-label="Floor plans">
<div class="toolbar" role="toolbar" aria-label="Plan tools">
<label>Showing <select id="levelSel"></select></label>
<span class="tools" id="tools">
<button data-tool="select" aria-pressed="true">Select / move</button>
<button data-tool="wall">Wall</button>
<button data-tool="room">Room</button>
<button data-tool="door">Door</button>
<button data-tool="window">Window</button>
<button data-tool="slider">Slider</button>
<button data-tool="garage">Garage door</button>
<button data-tool="egress">Egress window</button>
<button data-tool="stairs">Stairs</button>
<button data-tool="furn">Furniture</button>
<button data-tool="tree">Tree</button>
<button data-tool="trench">Trench</button>
<button data-tool="measure">Measure</button>
</span>
<label><input type="checkbox" id="roomWalls" checked> Room adds walls</label>
<label>Snap <select id="snapSel"><option value="1">1"</option><option value="6" selected>6"</option><option value="12">1'</option></select></label>
<button id="zoomIn" aria-label="Zoom in">+</button><button id="zoomOut" aria-label="Zoom out">-</button><button id="zoomFit">Fit</button>
</div>
<div class="planwrap">
<aside class="side left" aria-label="Objects on this level">
<h2>Add</h2>
<div class="addbtns">
<button id="addHouse">House</button>
<label class="sr" for="garageCars">Garage size</label>
<select id="garageCars"><option value="1">1-car 12x24</option><option value="2" selected>2-car 24x24</option><option value="3">3-car 36x24</option></select>
<button id="addGarage">Garage</button>
<button id="addWing">Wing (L shape)</button>
</div>
<div class="floorOnly">
<h2>Rooms</h2>
<button id="detectRooms">Find rooms from the walls</button>
<p class="small muted">Makes a room for every space the walls close in, with its square feet. Rooms already drawn keep their names and finishes.</p>
<h2>Stairs</h2>
<div class="furnadd">
<label>Shape <select id="stairShape"></select></label>
<label>Turn <select id="stairTurn"></select></label>
<label>Direction <select id="stairDir"></select></label>
<label>Width <input id="stairW" value="3'"></label>
<button id="addStairs">Add stairs to selected room</button>
<p class="small muted">Stairs go up from the floor shown to the next floor and cut an opening in it. Or press the Stairs tool and click where the bottom corner goes.</p>
</div>
</div>
<div class="siteOnly">
<h2>Lot items</h2>
<div class="furnadd">
<label>Item <select id="siteType"></select></label>
<label class="treeOpt">Tree kind <select id="treeKind"></select></label>
<label class="treeOpt">Tree size <select id="treeSize"></select></label>
<label class="trenchOpt">Utility <select id="trenchUtil"></select></label>
<label><span class="trenchOpt">Start </span>From the left lot line <input id="siteX" value="30"></label>
<label><span class="trenchOpt">Start </span>From the street <input id="siteY" value="20"></label>
<label class="trenchOpt">Ends at <select id="trenchTo"></select></label>
<label class="trenchOpt trenchEnd">End from the left lot line <input id="siteX2" value="30"></label>
<label class="trenchOpt trenchEnd">End from the street <input id="siteY2" value="40"></label>
<button id="addSite">Add to the lot</button>
<p class="small muted">Or use the Tree tool, or the Trench tool (click each bend, double-click to finish) on the site plan.</p>
</div>
</div>
<h2 class="floorOnly">Furniture & lights</h2>
<div class="furnadd floorOnly">
<label>Group <select id="furnCat"></select></label>
<label>Item <select id="furnType"></select></label>
<label>Where <select id="furnSpot">
<option value="middle">Middle of the room</option>
<option value="back">Against the back wall</option>
<option value="front">Against the front wall</option>
<option value="left">Against the left wall</option>
<option value="right">Against the right wall</option>
</select></label>
<button id="addFurn">Add to selected room</button>
<p class="small muted">Select a room first (or it goes in the first building). Or press the Furniture tool and click on the plan.</p>
</div>
<h2>On this level</h2>
<ul id="objList" class="objlist"></ul>
<h2>Description</h2>
<p id="describe" class="muted small"></p>
</aside>
<svg id="planSvg" tabindex="0" role="img" aria-label="Floor plan drawing. Use the object list and properties panel to edit by keyboard."></svg>
<aside class="side right" aria-label="Properties" id="props"><p class="muted">Select something to change it.</p></aside>
</div>
<p class="hint muted">Drag to pan, scroll to zoom. Wall tool: click start, click end, double-click to stop (Shift for angles). Room tool: drag a box. Door / window tools: click on a wall. Furniture tool: click inside a room (items with a back go against the nearest wall). Arrow keys move the selected item; R turns furniture; Delete removes it.</p>
</section>
<!-- ======================= 3D -->
<section id="tab-three" class="tab" role="tabpanel" aria-label="3D view">
<div class="toolbar">
<label>Time <select id="dayNight"><option value="day">Day</option><option value="night">Night</option></select></label>
<label><input type="checkbox" id="lightsOn"> Lights on</label>
<label>Sun <input type="range" id="hour" min="6" max="20" step="0.25" value="15" aria-describedby="hourOut"> <output id="hourOut">3:00 pm</output></label>
<label><input type="checkbox" id="showFurn" checked> Furniture</label>
<label><input type="checkbox" id="showRoof" checked> Roof</label>
<label><input type="checkbox" id="showGround" checked> Ground</label>
<label>Show floors up to <select id="maxLevel"></select></label>
<button id="walkBtn">Walk through</button>
<select id="walkLevel" aria-label="Walk on level"></select>
<button id="viewHome">Reset view</button>
<button id="shot">Save picture</button>
<label>Photo size <select id="photoSize"><option value="1920">1920 wide (HD)</option><option value="2560" selected>2560 wide</option><option value="3840">3840 wide (4K)</option></select></label>
<button id="photoBtn">Photo render</button>
</div>
<div id="threeHost" class="threehost"><p id="threeMsg" class="muted center">Loading 3D...</p></div>
<p class="hint muted">Orbit: drag. Pan: right-drag. Zoom: scroll. Walk: click the view, then W A S D or arrow keys, Q / E to turn, Shift to go faster, drag to look around.</p>
</section>
<!-- ======================= ELEVATIONS -->
<section id="tab-elev" class="tab" role="tabpanel" aria-label="Elevations">
<div class="toolbar"><label>Side <select id="elevSide"></select></label></div>
<svg id="elevSvg" role="img" aria-label="Elevation drawing"></svg>
</section>
<!-- ======================= SITE -->
<section id="tab-site" class="tab" role="tabpanel" aria-label="Site and grading">
<div class="cols">
<form class="card" id="lotForm" onsubmit="return false">
<h2>Lot</h2>
<p class="muted small">Lengths: type feet like 100 or 100' 6".</p>
<label>Lot width along the street <input name="w"></label>
<label>Lot depth from the street <input name="d"></label>
<label>Front setback <input name="front"></label>
<label>Side setbacks <input name="side"></label>
<label>Rear setback <input name="rear"></label>
<label class="row"><input type="checkbox" name="drive"> Draw driveway to garage doors</label>
<label class="row"><input type="checkbox" name="arrows"> Show slope arrows (which way water runs) on the site plan</label>
</form>
<div class="card" id="siteItemsCard">
<h2>Trees, well, septic and trenches</h2>
<div id="siteItemsOut"></div>
</div>
<form class="card" id="groundForm" onsubmit="return false">
<h2>Ground elevation</h2>
<p class="muted small">Type the height of the ground at each lot corner in feet (any starting number, for example 0 at the lowest corner), or look it up from the address.</p>
<label>Front left corner <input name="fl"></label>
<label>Front right corner <input name="fr"></label>
<label>Back left corner <input name="bl"></label>
<label>Back right corner <input name="br"></label>
<button id="setCorners">Use these corner heights</button>
<h3>Look up from the address</h3>
<p class="muted small">Sends the address to the US Census geocoder and the lot points to the USGS elevation service (both free, US only). The lot is assumed to face south toward the street.</p>
<label>Street address <input name="address" autocomplete="street-address"></label>
<button id="lookup">Get ground heights</button>
<p id="lookupStatus" class="small" role="status"></p>
</form>
<div class="card" id="gradeCard">
<h2>Site prep and grading</h2>
<div id="gradeOut"></div>
</div>
</div>
</section>
<!-- ======================= MATERIALS -->
<section id="tab-mat" class="tab" role="tabpanel" aria-label="Materials list">
<div class="card wide"><h2>Rough materials list</h2><p class="muted small">Estimates for planning only. Add 10% waste for siding and flooring. Have a builder check before ordering.</p><div id="matOut"></div></div>
<div class="card wide"><h2>Rough cost estimate</h2><p class="muted small">Typical installed prices (labor and materials) for planning only. Prices vary a lot by area: change any price below to match your quotes. Your prices are saved with this project.</p><div id="costOut" aria-live="polite"></div>
<h3>Unit prices</h3><div id="priceOut"></div><button id="resetPrices">Reset all prices to typical</button></div>
</section>
<!-- ======================= PRINT -->
<section id="tab-print" class="tab" role="tabpanel" aria-label="Print">
<div class="card wide">
<h2>Print plans to scale</h2>
<div class="row gap">
<label>Scale <select id="printScale"></select></label>
<label>Paper <select id="printPaper"></select></label>
</div>
<fieldset><legend>Sheets</legend><div id="sheetList"></div></fieldset>
<fieldset><legend>On floor plans</legend>
<label><input type="checkbox" id="printFurn" checked> Furniture, cabinets and appliances</label>
<label><input type="checkbox" id="printLights" checked> Light fixtures</label>
</fieldset>
<button id="doPrint" class="primary">Print</button>
<p class="muted small">In the print dialog choose "Actual size" or 100%. Then check with a ruler: the black and white bar on each page is exactly 1 inch.</p>
</div>
</section>
</main>
<dialog id="projDlg" aria-labelledby="projTitle">
<h2 id="projTitle">Projects</h2>
<ul id="projList" class="objlist"></ul>
<h3>Start a new project</h3>
<div class="row gap">
<select id="newKind" aria-label="New project type"><option value="house+garage">House + 2-car garage (furnished)</option><option value="twostory">Two-story house with basement + garage (furnished)</option><option value="garage">Garage only</option><option value="blank">Empty lot</option></select>
<button id="newProj">Create</button>
</div>
<div id="shareBox"></div>
<p><button id="closeDlg">Close</button></p>
</dialog>
<dialog id="photoDlg" aria-labelledby="photoTitle">
<h2 id="photoTitle">Photo render</h2>
<p id="photoMsg" class="small" role="status"></p>
<img id="photoImg" alt="" style="max-width:100%;display:block">
<p class="row gap"><a id="photoDl" download="design-photo.jpg" class="btnlink">Download picture</a><button id="photoClose">Close</button></p>
</dialog>
<div id="printArea" aria-hidden="true"></div>
<script type="module" src="/static/js/main.js"></script>
</body>
</html>
EOF
style.css: dark theme, plus the print rules.
cat > /root/build/style.css <<'EOF'
:root { --bg:#16181b; --panel:#1e2024; --line:#6a6a6a; --text:#e8e8e8; --muted:#b0b0b0; --acc:#d98a3a; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, sans-serif; display: flex; flex-direction: column; }
button, select, input { font: inherit; color: var(--text); background: #2a2d32; border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; }
button { cursor: pointer; }
button:hover { border-color: var(--acc); }
button[aria-pressed="true"], button.primary { background: var(--acc); color: #111; font-weight: 700; border-color: var(--acc); }
:focus-visible { outline: 3px solid #ffbf47; outline-offset: 1px; }
input[type=color] { padding: 0; width: 44px; height: 30px; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.muted { color: var(--muted); } .small { font-size: 13px; } .grow { flex: 1; } .center { text-align: center; padding-top: 30vh; }
.top { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.top h1 { font-size: 18px; margin: 0 8px 0 0; color: var(--acc); }
.top form { margin: 0; }
.pname input { width: 220px; font-weight: 700; }
.tabs { display: flex; gap: 4px; padding: 6px 12px 0; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
.tabs button { border-radius: 6px 6px 0 0; border-bottom: none; }
.tabs button[aria-selected="true"] { background: var(--acc); color: #111; font-weight: 700; }
main { flex: 1; min-height: 0; display: flex; }
.tab { display: none; flex: 1; flex-direction: column; min-height: 0; padding: 8px 12px; overflow: auto; }
.tab.on { display: flex; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding-bottom: 8px; }
.tools { display: inline-flex; gap: 3px; flex-wrap: wrap; }
.planwrap { flex: 1; display: flex; min-height: 420px; gap: 8px; }
#planSvg, #elevSvg { flex: 1; background: #f7f7f5; border: 1px solid var(--line); border-radius: 6px; touch-action: none; min-width: 0; }
#elevSvg { min-height: 500px; }
#planSvg text, #elevSvg text { font-family: Arial, sans-serif; }
.side { width: 270px; flex: none; overflow: auto; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; }
.side h2 { font-size: 14px; margin: 10px 0 6px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.addbtns { display: flex; gap: 4px; flex-wrap: wrap; }
.colfield { margin: 6px 0 10px; }
.swatches { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
.swatches button { width: 22px; height: 22px; padding: 0; border: 1px solid #888; border-radius: 4px; }
.swatches button:hover { outline: 2px solid var(--acc); }
.furnadd label { display: block; margin: 4px 0; font-size: 13px; color: var(--muted); }
.furnadd select { display: block; width: 100%; margin-top: 2px; color: var(--text); }
.furnadd button { margin-top: 4px; }
.objlist { list-style: none; padding: 0; margin: 0; }
.objlist li { margin: 2px 0; }
.objlist button { width: 100%; text-align: left; background: transparent; border-color: transparent; padding: 3px 6px; }
.objlist button[aria-current="true"] { border-color: var(--acc); background: #3a2d1f; }
.objlist .sub { padding-left: 14px; }
#props label { display: block; margin: 6px 0; font-size: 13px; color: var(--muted); }
#props label input:not([type=checkbox]):not([type=color]), #props label select { display: block; width: 100%; margin-top: 2px; color: var(--text); }
#props h2 { color: var(--acc); }
#props .row { display: flex; gap: 6px; } #props .row > * { flex: 1; }
.hint { margin: 6px 0 0; font-size: 13px; }
.threehost { flex: 1; min-height: 420px; position: relative; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.threehost canvas { display: block; }
.cols { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-start; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; width: 330px; }
.card.wide { width: auto; max-width: 900px; }
.card h2 { margin: 0 0 8px; font-size: 17px; }
.card h3 { font-size: 15px; margin: 14px 0 4px; }
.card label { display: block; margin: 8px 0; }
.card label input:not([type=checkbox]) { display: block; width: 100%; margin-top: 2px; }
.row { display: flex; align-items: center; } .gap { gap: 10px; }
table { border-collapse: collapse; width: 100%; margin: 6px 0 14px; }
th, td { border-bottom: 1px solid #3a3d42; padding: 4px 8px; text-align: left; } td.n, th.n { text-align: right; }
fieldset { border: 1px solid var(--line); border-radius: 6px; margin: 10px 0; }
fieldset label { display: block; margin: 4px 0; }
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 10px; width: min(560px, 94vw); }
dialog::backdrop { background: rgba(0,0,0,.6); }
#printArea { display: none; }
[hidden] { display: none !important; }
.furnadd input { display: block; width: 100%; margin-top: 2px; color: var(--text); }
.btnlink { display: inline-block; color: #111; background: var(--acc); border-radius: 6px; padding: 5px 9px; font-weight: 700; text-decoration: none; }
#photoDlg { width: min(1100px, 96vw); }
.ok { color: #8fd18f; } .bad { color: #ff9a8a; font-weight: 700; }
.prices td { padding: 2px 8px; } .prices input { width: 110px; text-align: right; }
.cost td.n { white-space: nowrap; }
@media (max-width: 900px) { .planwrap { flex-direction: column; } .side { width: auto; } #planSvg { min-height: 420px; } }
@media print {
body > *:not(#printArea) { display: none !important; }
body { background: #fff; color: #000; display: block; }
#printArea { display: block; }
.sheet { page-break-after: always; break-after: page; overflow: hidden; }
.sheet:last-child { page-break-after: auto; break-after: auto; }
.sheet svg.draw { display: block; }
.titleblock { display: flex; justify-content: space-between; align-items: flex-start; border-top: 1px solid #000; font: 9pt Arial, sans-serif; padding-top: 3pt; height: 0.75in; }
}
EOF
model.js: the data model: units, the finishes catalog, the starter house and garages, ground heights, grading, cut and fill, contours, roof geometry and the materials list.
cat > /root/build/model.js <<'EOF'
// Project data model. Every length is in INCHES.
// Plan coordinates: x = east (right), y = north (up), street along y = 0.
// Rooms and interior walls are stored relative to their structure's south-west corner.
// Elevations (z) are inches above the lowest point of the lot's original ground.
export const FT = 12;
export const uid = () => Math.random().toString(36).slice(2, 10);
// ------------------------------------------------------------ units
export function fmtLen(inches, opts = {}) {
if (inches == null || isNaN(inches)) return '-';
const neg = inches < 0;
let t = Math.round(Math.abs(inches) * 2) / 2; // nearest 1/2 inch
let ft = Math.floor(t / 12), inch = t - ft * 12;
const ins = inch % 1 ? inch.toFixed(1) : String(inch);
let s = ft && inch ? `${ft}' ${ins}"` : ft ? `${ft}'` : `${ins}"`;
if (opts.words) s = ft && inch ? `${ft} feet ${ins} inches` : ft ? `${ft} feet` : `${ins} inches`;
return (neg ? '-' : '') + s;
}
// Accepts 24, 24.5, 24', 24'6", 24' 6, 24 6, 6", 6in, 24ft 6in. Plain numbers are FEET.
export function parseLen(str) {
if (str == null) return null;
let s = String(str).trim().toLowerCase().replace(/feet|foot|ft/g, "'").replace(/inches|inch|in/g, '"');
if (!s) return null;
const neg = s.startsWith('-');
if (neg) s = s.slice(1).trim();
let m;
if ((m = s.match(/^(\d+(?:\.\d+)?)\s*"$/))) return (neg ? -1 : 1) * +m[1];
if ((m = s.match(/^(\d+(?:\.\d+)?)\s*'?\s*$/))) return (neg ? -1 : 1) * +m[1] * 12;
if ((m = s.match(/^(\d+(?:\.\d+)?)\s*'?\s*[\s-]?\s*(\d+(?:\.\d+)?)\s*"?$/))) return (neg ? -1 : 1) * (+m[1] * 12 + +m[2]);
return null;
}
export const sqft = (in2) => in2 / 144;
// ------------------------------------------------------------ finishes catalog
export const SIDING = {
vinyl: 'Vinyl lap siding', fibercement: 'Fiber cement lap (HardiePlank style)', board: 'Board and batten',
shake: 'Cedar shake', brick: 'Brick', stone: 'Stone veneer', stucco: 'Stucco',
};
export const ROOFING = { asphalt: 'Asphalt shingles', metal: 'Standing seam metal' };
export const ROOF_TYPES = { gable: 'Gable', hip: 'Hip', shed: 'Shed (one slope)', flat: 'Flat' };
export const FLOORING = {
oak: 'Oak hardwood', walnut: 'Walnut hardwood', lvp: 'Luxury vinyl plank (gray oak)',
tile: 'Porcelain tile 12x24', tile12: 'Ceramic tile 12x12', carpet: 'Carpet', concrete: 'Polished concrete',
};
export const FOUNDATIONS = { slab: 'Slab on grade', crawl: 'Crawlspace', basement: 'Full basement', walkout: 'Walkout basement' };
export const OPENINGS = {
door: { name: 'Door', w: 36, h: 80, sill: 0 },
window: { name: 'Window', w: 36, h: 48, sill: 36 },
slider: { name: 'Sliding glass door', w: 72, h: 80, sill: 0 },
garage: { name: 'Garage door', w: 192, h: 84, sill: 0 },
egress: { name: 'Egress window (basement, with window well)', w: 48, h: 48, sill: 40 },
};
export const isGlass = (o) => o.kind === 'window' || o.kind === 'slider' || o.kind === 'egress';
export const EGRESS_STYLES = { casement: 'Casement (swings out, nearly all of it opens)', slider: 'Horizontal slider (half of it opens)' };
// Typical code egress check (IRC R310): net clear opening 5.7 sq ft, clear width 20", clear height 24",
// sill no more than 44" above the floor. Clear size allows for the frame and sash.
export const EGRESS = { net: 5.7, minW: 20, minH: 24, maxSill: 44 };
export function egressCheck(o) {
const cw = o.style === 'slider' ? o.w / 2 - 4 : o.w - 6, ch = o.h - 6, net = Math.max(0, cw) * Math.max(0, ch) / 144;
const bad = [];
if (net < EGRESS.net) bad.push(`clear opening ${net.toFixed(1)} sq ft is under ${EGRESS.net}`);
if (cw < EGRESS.minW) bad.push(`clear width ${fmtLen(cw)} is under 20"`);
if (ch < EGRESS.minH) bad.push(`clear height ${fmtLen(ch)} is under 24"`);
if (o.sill > EGRESS.maxSill) bad.push(`sill ${fmtLen(o.sill)} is over 44" above the floor`);
const text = bad.length ? `Does NOT meet typical egress size: ${bad.join('; ')}.`
: `Meets typical egress size: clear opening ${net.toFixed(1)} sq ft (5.7 needed), ${fmtLen(cw)} wide by ${fmtLen(ch)} high, sill ${fmtLen(o.sill)} (44" most).`;
return { ok: !bad.length, net, cw, ch, bad, text };
}
// Window well for an egress window whose sill is below the finished ground: 6" wider than the window
// on each side, `wellProj` (36") out from the wall, 6" of gravel below the sill, 3" above the ground.
export function windowWell(p, s, o) {
if (o.kind !== 'egress' || !isExtWall(o)) return null;
const w = extWalls(s)[o.wall], dx = (w.b[0] - w.a[0]) / w.len, dy = (w.b[1] - w.a[1]) / w.len, n = [dy, -dx];
const mid = o.off + o.w / 2, gx = w.a[0] + dx * mid + n[0] * 18, gy = w.a[1] + dy * mid + n[1] * 18;
const grade = designAt(p, gx, gy), sillZ = levelBase(s, o.lvl) + o.sill;
if (grade <= sillZ + 2) return null;
const proj = o.wellProj || 36, t0 = o.off - 6, t1 = o.off + o.w + 6;
const P = (t, k) => [w.a[0] + dx * t + n[0] * k, w.a[1] + dy * t + n[1] * k];
const c = [P(t0, 0), P(t1, 0), P(t1, proj), P(t0, proj)];
const xs = c.map(q => q[0]), ys = c.map(q => q[1]);
const bottom = sillZ - 6, top = grade + 3, area = (t1 - t0) * proj / 144;
const notes = [];
if (area < 9) notes.push(`well floor ${area.toFixed(1)} sq ft is under 9`);
if (proj < 36) notes.push('well sticks out less than 36"');
return { w, n, d: [dx, dy], t0, t1, proj, top, bottom, grade, depth: grade - bottom, area, ladder: grade - sillZ > 44,
rect: { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) }, corners: c, notes };
}
// ------------------------------------------------------------ outside details and inside finishes
export const SOFFITS = { vented: 'Vented vinyl soffit', bead: 'Beadboard (wood look)', none: 'None (open rafters)' };
export const GUTTERS = { k: '5" K-style gutters', half: '6" half-round gutters', none: 'No gutters' };
export const BONDS = { running: 'Running bond', stack: 'Stack bond', flemish: 'Flemish bond' };
export const ACCENTS = { none: 'No accent band', base: 'Herringbone band at the bottom of the walls', frieze: 'Herringbone band under the eaves' };
export const WALLPAPER = { none: 'None (paint)', stripe: 'Stripes', damask: 'Damask', floral: 'Small floral', check: 'Gingham check', grass: 'Grasscloth' };
export const BACKSPLASH = { none: 'None', subway: 'Subway tile 3x6', hex: 'Hexagon tile', mosaic: 'Glass mosaic 1"', herring: 'Herringbone tile' };
export const WAINSCOT_IN = { none: 'None', bead: 'Beadboard', panel: 'Raised panel', batten: 'Board and batten' };
export const CEILINGS = { flat: 'Flat', vaulted: 'Vaulted (top floor only)', tray: 'Tray (dropped edge)', coffered: 'Coffered (beam grid)' };
// defaults for outside details on buildings saved before these existed
export const extDetails = (s) => ({ soffit: s.ext.soffit || 'vented', soffitColor: s.ext.soffitColor || s.ext.trim,
fascia: s.ext.fascia ?? 6, fasciaColor: s.ext.fasciaColor || s.ext.trim, gutter: s.ext.gutter || 'k',
gutterColor: s.ext.gutterColor || s.ext.trim, bond: s.ext.bond || 'running', accent: s.ext.accent || 'none', accentH: s.ext.accentH || 16 });
// Top of a band "under the eaves": just below the soffit, which meets the wall below the wall top.
export function friezeTop(s) {
const ed = extDetails(s);
if (s.roof.type === 'flat' || ed.soffit === 'none') return plateZ(s);
return Math.min(plateZ(s), plateZ(s) - s.roof.overhang * s.roof.pitch / 12 - Math.max(ed.fascia, 3) + 0.5);
}
// Roof edges: eaves (level, outside the walls: fascia, soffit, gutter) and rakes (sloped gable edges: fascia).
// Downspouts sit at the building corners under each eave, plus one in the middle of eaves over 40 feet.
export function roofEdges(s) {
const rg = roofGeometry(s), o = s.roof.overhang, eaves = [], rakes = [];
const out = (x, y) => x < s.x - 1 || x > s.x + s.w + 1 || y < s.y - 1 || y > s.y + s.d + 1;
const cx = s.x + s.w / 2, cy = s.y + s.d / 2;
for (const poly of rg.roof) for (let i = 0; i < poly.length; i++) {
const A = poly[i], B = poly[(i + 1) % poly.length];
if (!out((A[0] + B[0]) / 2, (A[1] + B[1]) / 2)) continue;
const len = Math.hypot(B[0] - A[0], B[1] - A[1]);
if (len < 1) continue;
if (Math.abs(A[2] - B[2]) < 0.5) {
const d = [(B[0] - A[0]) / len, (B[1] - A[1]) / len];
let n = [d[1], -d[0]];
if (n[0] * ((A[0] + B[0]) / 2 - cx) + n[1] * ((A[1] + B[1]) / 2 - cy) < 0) n = [-n[0], -n[1]];
eaves.push({ a: A, b: B, d, n, len, z: A[2], mitre: s.roof.type === 'hip' || s.roof.type === 'flat' });
} else rakes.push({ a: A, b: B, len: Math.hypot(len, B[2] - A[2]) });
}
const spouts = [], seen = new Set();
if (s.roof.type !== 'flat') for (const e of eaves) {
const ts = [o, e.len - o];
if (e.len > 40 * FT + 2 * o) ts.push(e.len / 2);
for (const t of ts) {
const x = e.a[0] + e.d[0] * t - e.n[0] * (o - 3), y = e.a[1] + e.d[1] * t - e.n[1] * (o - 3);
const k = Math.round(x / 12) + ',' + Math.round(y / 12);
if (seen.has(k)) continue;
seen.add(k); spouts.push({ x, y, top: e.z - 4, n: e.n, out: o - 3 });
}
}
return { eaves, rakes, spouts, o };
}
export const DOOR_STYLES = {
panel: '6-panel', craftsman: 'Craftsman (glass panes at the top)', halfglass: 'Half glass', fullglass: 'Full glass',
flush: 'Flush / plain', double: 'Double doors', barn: 'Barn door (inside walls only)',
};
export const GARAGE_STYLES = {
panel: 'Raised panel', carriage: 'Carriage house (window row)', panelwin: 'Raised panel with window row',
flush: 'Flush / modern', glass: 'Full-view glass, aluminum frame',
};
export const isExtWall = (o) => o.wall.length === 1 && 'NESW'.includes(o.wall);
// Door and garage door color: the door's own color, else the building's (inside doors default to white).
export const doorColor = (o, s) => o.color || (o.kind === 'door' && !isExtWall(o) ? '#f4f2ee' : s.ext.door || s.ext.trim);
// Faces of a door or garage door, shared by the 3D textures, the 3D glass and the elevations.
// Rects are fractions of the whole opening, measured from its top-left: [x, y, w, h, type],
// type 'panel' (raised panel), 'glass', or 'line' (groove / joint). leaves = leaf edges as fractions.
export function doorLayout(o) {
const R = [];
if (o.kind === 'garage') {
const st = GARAGE_STYLES[o.style] ? o.style : 'panel', rows = 4, cols = Math.max(2, Math.round(o.w / 24));
for (let j = 1; j < rows; j++) R.push([0, j / rows, 1, 0, 'line']);
if (st === 'glass') {
const gc = Math.max(2, Math.round(o.w / 36));
for (let j = 0; j < rows; j++) for (let i = 0; i < gc; i++) R.push([i / gc + 0.012, j / rows + 0.035, 1 / gc - 0.024, 1 / rows - 0.07, 'glass']);
} else if (st === 'carriage') {
for (let i = 1; i < 24; i++) R.push([i / 24, 0.25, 0, 0.75, 'line']);
R.push([0.5, 0, 0, 1, 'line']);
const n = 8;
for (let i = 0; i < n; i++) R.push([i / n + 0.02, 0.05, 1 / n - 0.04, 0.15, 'glass']);
} else if (st === 'panel' || st === 'panelwin') {
for (let j = 0; j < rows; j++) for (let i = 0; i < cols; i++)
R.push([i / cols + 0.012, j / rows + 0.04, 1 / cols - 0.024, 1 / rows - 0.08, st === 'panelwin' && j === 0 ? 'glass' : 'panel']);
} else for (let j = 0; j < rows; j++) R.push([0, j / rows + 0.125, 1, 0, 'line']); // flush: fine grooves
return { rects: R, leaves: [0, 1], style: st };
}
const st = DOOR_STYLES[o.style] ? o.style : 'panel';
const leaf = (x0, x1, s) => {
const W = x1 - x0, P = (x, y, w, h, t = 'panel') => R.push([x0 + x * W, y, w * W, h, t]);
if (s === 'panel') for (const [x, y, w, h] of [[0.12, 0.06, 0.34, 0.22], [0.54, 0.06, 0.34, 0.22], [0.12, 0.33, 0.34, 0.28], [0.54, 0.33, 0.34, 0.28], [0.12, 0.66, 0.34, 0.28], [0.54, 0.66, 0.34, 0.28]]) P(x, y, w, h);
if (s === 'craftsman') { for (let i = 0; i < 3; i++) P(0.13 + i * 0.25, 0.07, 0.24, 0.22, 'glass'); P(0.1, 0.33, 0.8, 0.025, 'panel'); for (let i = 0; i < 3; i++) P(0.13 + i * 0.25, 0.4, 0.22, 0.52); }
if (s === 'halfglass') { P(0.15, 0.07, 0.7, 0.42, 'glass'); P(0.12, 0.58, 0.34, 0.34); P(0.54, 0.58, 0.34, 0.34); }
if (s === 'fullglass') P(0.15, 0.06, 0.7, 0.88, 'glass');
if (s === 'barn') { for (let i = 1; i < 6; i++) P(i / 6, 0, 0, 1, 'line'); P(0, 0.06, 1, 0, 'line'); P(0, 0.94, 1, 0, 'line'); P(0, 0.5, 1, 0, 'line'); }
};
if (st === 'double') { leaf(0, 0.5, 'panel'); leaf(0.5, 1, 'panel'); return { rects: R, leaves: [0, 0.5, 1], style: st }; }
leaf(0, 1, st);
return { rects: R, leaves: [0, 1], style: st };
}
export const FOUNDATION_WALL ={ poured: 'Poured concrete', block: 'Concrete block', icf: 'ICF (insulated concrete forms)' };
// ------------------------------------------------------------ new projects
function house(x, y) {
const id = uid();
return {
id, kind: 'house', name: 'House', x, y, w: 40 * FT, d: 28 * FT,
floors: 1, wallH: 108, foundation: 'crawl', fwall: 'poured', basementH: 96,
ffeAuto: true, ffe: 0,
roof: { type: 'gable', pitch: 6, overhang: 12, ridge: 'auto', mat: 'asphalt', color: '#4a4d52' },
ext: { mat: 'vinyl', color: '#c9c3b6', wainscot: 0, wainscotMat: 'stone', trim: '#f4f2ee', door: '#7a2e22' },
floor: 'oak', wallColor: '#ece6da',
};
}
function garage(x, y, cars = 2) {
const w = { 1: 12, 2: 24, 3: 36 }[cars] * FT;
return {
id: uid(), kind: 'garage', name: `${cars}-car garage`, x, y, w, d: 24 * FT,
floors: 1, wallH: 108, foundation: 'slab', fwall: 'poured', basementH: 96,
ffeAuto: true, ffe: 0,
roof: { type: 'gable', pitch: 6, overhang: 12, ridge: 'auto', mat: 'asphalt', color: '#4a4d52' },
ext: { mat: 'vinyl', color: '#c9c3b6', wainscot: 0, wainscotMat: 'stone', trim: '#f4f2ee', door: '#f4f2ee' },
floor: 'concrete', wallColor: '#d8d4cc',
};
}
export function addGarage(p, cars, attachTo) {
let x = 10 * FT, y = 40 * FT;
if (attachTo) { x = attachTo.x + attachTo.w; y = attachTo.y; }
const g = garage(x, y, cars);
p.structures.push(g);
const dw = cars === 1 ? 108 : cars === 2 ? 192 : 108;
const n = cars === 3 ? 3 : 1;
const gap = (g.w - n * dw) / (n + 1);
for (let i = 0; i < n; i++)
p.openings.push({ id: uid(), s: g.id, lvl: 0, wall: 'S', kind: 'garage', off: gap + i * (dw + gap), w: dw, h: 84, sill: 0, style: 'panel' });
p.openings.push({ id: uid(), s: g.id, lvl: 0, wall: 'E', kind: 'door', off: 60, w: 36, h: 80, sill: 0, style: 'panel' });
p.openings.push({ id: uid(), s: g.id, lvl: 0, wall: 'N', kind: 'window', off: g.w / 2 - 18, w: 36, h: 36, sill: 48, style: 'grid' });
return g;
}
export function addHouse(p, x = 20 * FT, y = 40 * FT) {
const h = house(x, y);
p.structures.push(h);
return h;
}
// Places building s against one side of building o (right, left, back or front), lined up on o's corner.
export function attachTo(s, o, side) {
if (side === 'right') { s.x = o.x + o.w; s.y = o.y; }
else if (side === 'left') { s.x = o.x - s.w; s.y = o.y; }
else if (side === 'back') { s.x = o.x; s.y = o.y + o.d; }
else if (side === 'front') { s.x = o.x; s.y = o.y - s.d; }
}
// A wing is a second house block attached to the back-left of the house, making an L.
// It copies the house's roof, siding and foundation; the shared wall is not doubled (see sharedWalls).
export function addWing(p, main) {
const g = JSON.parse(JSON.stringify(main));
Object.assign(g, { id: uid(), name: 'Wing', w: 16 * FT, d: 20 * FT });
g.roof.ridge = 'auto';
attachTo(g, main, 'back');
p.structures.push(g);
return g;
}
export function newProject(kind = 'house+garage') {
const p = {
version: 1, name: 'My house',
site: {
w: 100 * FT, d: 150 * FT, address: '', lat: null, lon: null,
setback: { front: 25 * FT, side: 10 * FT, rear: 25 * FT },
// Ground elevation grid (inches), n x n points from the front-left corner, row by row.
grid: { n: 2, z: [0, 0, 30, 30] }, drive: true, arrows: true, items: [],
},
structures: [], walls: [], rooms: [], openings: [], furniture: [], stairs: [], prices: {},
};
if (kind === 'garage') {
p.name = 'My garage';
addGarage(p, 2, null).x = 38 * FT;
return p;
}
if (kind === 'blank') { p.name = 'New project'; return p; }
const h = addHouse(p, 20 * FT, 40 * FT);
const s = h.id, R = (name, x, y, w, d, floor) => p.rooms.push({ id: uid(), s, lvl: 0, name, x, y, w, d, floor });
// 40' x 28' ranch: living + kitchen across the front/back, two bedrooms + bath on the left.
R('Living room', 16 * FT, 0, 24 * FT, 14 * FT, 'oak');
R('Kitchen / dining', 16 * FT, 14 * FT, 24 * FT, 14 * FT, 'tile');
R('Bedroom 1', 0, 0, 16 * FT, 13 * FT, 'carpet');
R('Bedroom 2', 0, 18 * FT, 16 * FT, 10 * FT, 'carpet');
R('Bath', 0, 13 * FT, 9 * FT, 5 * FT, 'tile12');
R('Hall', 9 * FT, 13 * FT, 7 * FT, 5 * FT, 'oak');
const W = (ax, ay, bx, by) => p.walls.push({ id: uid(), s, lvl: 0, a: [ax, ay], b: [bx, by], t: 4.5, color: '' });
W(16 * FT, 0, 16 * FT, 28 * FT);
W(0, 13 * FT, 16 * FT, 13 * FT);
W(0, 18 * FT, 16 * FT, 18 * FT);
W(9 * FT, 13 * FT, 9 * FT, 18 * FT);
const O = (wall, kind, off, w, h, sill, style = 'grid') => p.openings.push({ id: uid(), s, lvl: 0, wall, kind, off, w, h, sill, style });
O('S', 'door', 22 * FT, 36, 80, 0, 'panel');
O('S', 'window', 30 * FT, 72, 54, 30);
O('S', 'window', 5 * FT, 48, 48, 36);
O('N', 'slider', 6 * FT, 72, 80, 0, 'plain');
O('N', 'window', 14 * FT, 48, 42, 42);
O('N', 'window', 30 * FT, 36, 48, 36);
O('W', 'window', 4 * FT, 48, 48, 36);
O('W', 'window', 19 * FT, 48, 48, 36);
addGarage(p, 2, h);
return p;
}
// ------------------------------------------------------------ geometry helpers
export const byId = (arr, id) => arr.find(o => o.id === id);
export const polyLen = (pts) => pts.reduce((a, q, i) => i ? a + Math.hypot(q[0] - pts[i - 1][0], q[1] - pts[i - 1][1]) : 0, 0);
// Exterior wall lines of a structure, counter-clockwise, outer face on the footprint.
export function extWalls(s) {
const { x, y, w, d } = s;
return {
S: { a: [x, y], b: [x + w, y], len: w },
E: { a: [x + w, y], b: [x + w, y + d], len: d },
N: { a: [x + w, y + d], b: [x, y + d], len: w },
W: { a: [x, y + d], b: [x, y], len: d },
};
}
export const SIDE_NAMES = { S: 'Front (south)', E: 'Right (east)', N: 'Back (north)', W: 'Left (west)' };
// Wall line of an opening in absolute plan coordinates.
export function openingWall(p, o) {
const s = byId(p.structures, o.s);
if (!s) return null;
if ('NESW'.includes(o.wall) && o.wall.length === 1) {
const w = extWalls(s)[o.wall];
return { ...w, t: 6, ext: true };
}
const iw = byId(p.walls, o.wall);
if (!iw) return null;
const a = [s.x + iw.a[0], s.y + iw.a[1]], b = [s.x + iw.b[0], s.y + iw.b[1]];
return { a, b, len: Math.hypot(b[0] - a[0], b[1] - a[1]), t: iw.t, ext: false };
}
// ------------------------------------------------------------ shared walls (attached garage, L-shaped wings)
const OPP = { N: 'S', S: 'N', E: 'W', W: 'E' };
// Stretches of outside wall that two touching buildings share. The house keeps its wall (or the
// first building, when both are the same kind); the other building leaves that stretch out.
export function sharedWalls(p) {
const out = [], S = p.structures;
for (let i = 0; i < S.length; i++) for (let j = i + 1; j < S.length; j++) {
let keep = S[i], drop = S[j];
if (keep.kind === 'garage' && drop.kind === 'house') [keep, drop] = [drop, keep];
const Wk = extWalls(keep), Wd = extWalls(drop);
for (const side of 'NESW') {
const a = Wk[side], b = Wd[OPP[side]];
const ax = side === 'N' || side === 'S' ? 1 : 0, k = 1 - ax; // ax must match, k runs along the wall
if (Math.abs(a.a[ax] - b.a[ax]) > 0.5) continue;
const lo = Math.max(Math.min(a.a[k], a.b[k]), Math.min(b.a[k], b.b[k]));
const hi = Math.min(Math.max(a.a[k], a.b[k]), Math.max(b.a[k], b.b[k]));
if (hi - lo < 6) continue;
const levels = levelsOf(keep).filter(l => levelsOf(drop).includes(l));
if (levels.length) out.push({ keep, keepSide: side, drop, dropSide: OPP[side], lo, hi, k, levels });
}
}
return out;
}
// Distance along outside wall w to the plan coordinate c (k = 0: x, k = 1: y).
const alongWall = (w, k, c) => (c - w.a[k]) * Math.sign(w.b[k] - w.a[k]);
// For one outside wall on one level: skip = stretches this building leaves out (the neighbour's
// wall is there), inside = stretches with the neighbour on the far side [t0, t1, neighbour],
// ops = the neighbour's openings in its left-out stretch, re-measured along this wall.
export function wallShare(p, s, side, lvl, shares = sharedWalls(p)) {
const w = extWalls(s)[side], skip = [], inside = [], ops = [];
for (const sh of shares) {
if (!sh.levels.includes(lvl)) continue;
const isDrop = sh.drop === s && sh.dropSide === side, isKeep = sh.keep === s && sh.keepSide === side;
if (!isDrop && !isKeep) continue;
const t0 = alongWall(w, sh.k, sh.lo), t1 = alongWall(w, sh.k, sh.hi);
const a = Math.min(t0, t1), b = Math.max(t0, t1);
if (isDrop) { skip.push([a, b]); continue; }
inside.push([a, b, sh.drop]);
const dw = extWalls(sh.drop)[sh.dropSide], sg = Math.sign(dw.b[sh.k] - dw.a[sh.k]);
for (const o of p.openings) {
if (o.s !== sh.drop.id || o.wall !== sh.dropSide || o.lvl !== lvl) continue;
const c0 = dw.a[sh.k] + sg * o.off, c1 = c0 + sg * o.w;
if (Math.max(c0, c1) <= sh.lo + 0.1 || Math.min(c0, c1) >= sh.hi - 0.1) continue;
ops.push({ ...o, off: Math.min(alongWall(w, sh.k, c0), alongWall(w, sh.k, c1)), mapped: true });
}
}
return { skip, inside, ops };
}
// Openings drawn in an outside wall: its own (less any in a left-out stretch) plus the neighbour's mapped ones.
export function wallOpenings(p, s, side, lvl, sh) {
const own = p.openings.filter(o => o.s === s.id && o.lvl === lvl && o.wall === side &&
!sh.skip.some(([a, b]) => o.off < b - 0.1 && o.off + o.w > a + 0.1));
return own.concat(sh.ops).sort((x, y) => x.off - y.off);
}
// Splits [t0, t1] of a wall into pieces tagged 'out' (normal), 'skip' (left out) or the neighbour building ('in').
export function splitShared(t0, t1, sh) {
const cuts = new Set([t0, t1]);
for (const [a, b] of [...sh.skip, ...sh.inside]) { if (a > t0 && a < t1) cuts.add(a); if (b > t0 && b < t1) cuts.add(b); }
const xs = [...cuts].sort((a, b) => a - b), out = [];
for (let i = 0; i < xs.length - 1; i++) {
const m = (xs[i] + xs[i + 1]) / 2;
const tag = sh.skip.some(([a, b]) => m > a && m < b) ? 'skip' : sh.inside.find(([a, b]) => m > a && m < b)?.[2] || 'out';
out.push([xs[i], xs[i + 1], tag]);
}
return out;
}
export function levelsOf(s) {
const L = [];
if (s.foundation === 'basement' || s.foundation === 'walkout') L.push(-1);
for (let i = 0; i < s.floors; i++) L.push(i);
return L;
}
export const levelName = (l) => l === -1 ? 'Basement' : `Floor ${l + 1}`;
export const FLOOR_DEPTH = 12; // floor framing between stories
// Bottom (finished floor) elevation of a level.
export function levelBase(s, lvl) {
if (lvl === -1) return s.ffe - FLOOR_DEPTH - s.basementH;
return s.ffe + lvl * (s.wallH + FLOOR_DEPTH);
}
export const plateZ = (s) => levelBase(s, s.floors - 1) + s.wallH;
// ------------------------------------------------------------ ground
export function groundAt(site, x, y) {
const { n, z } = site.grid;
const u = Math.min(Math.max(x / site.w, 0), 1) * (n - 1);
const v = Math.min(Math.max(y / site.d, 0), 1) * (n - 1);
const i = Math.min(Math.floor(u), n - 2), j = Math.min(Math.floor(v), n - 2);
const fu = u - i, fv = v - j;
const g = (a, b) => z[b * n + a];
return (g(i, j) * (1 - fu) + g(i + 1, j) * fu) * (1 - fv) + (g(i, j + 1) * (1 - fu) + g(i + 1, j + 1) * fu) * fv;
}
const APRON = 10 * FT; // graded transition around each building pad
// Finished grade right against the building (inches below the finished floor).
export function gradeDrop(s) {
if (s.foundation === 'crawl') return 24;
if (s.kind === 'garage' && s.foundation === 'slab') return 4;
return 8;
}
export function updateAutoFfe(p) {
for (const s of p.structures) {
if (!s.ffeAuto) continue;
const pts = [[s.x, s.y], [s.x + s.w, s.y], [s.x, s.y + s.d], [s.x + s.w, s.y + s.d], [s.x + s.w / 2, s.y + s.d / 2]];
const zs = pts.map(([x, y]) => groundAt(p.site, x, y));
// Walkout: floor set from the high side so the low side opens out.
const base = s.foundation === 'walkout' ? Math.max(...zs) : zs.reduce((a, b) => a + b) / zs.length;
s.ffe = Math.round(base + gradeDrop(s));
}
}
function distToRect(s, x, y) {
const dx = Math.max(s.x - x, 0, x - (s.x + s.w));
const dy = Math.max(s.y - y, 0, y - (s.y + s.d));
return Math.hypot(dx, dy);
}
// Designed (finished) ground after site prep.
export function designAt(p, x, y) {
let z = groundAt(p.site, x, y);
let best = null, bestD = Infinity;
for (const s of p.structures) {
const d = distToRect(s, x, y);
if (d < APRON && d < bestD) { best = s; bestD = d; }
}
if (!best) return z;
const pad = best.ffe - gradeDrop(best);
const t = bestD / APRON, k = t * t * (3 - 2 * t); // smooth blend from pad to natural ground
if (best.foundation === 'walkout' && z < pad) return z; // low side left natural so the basement opens out
return pad * (1 - k) + z * k;
}
// Cut / fill in cubic yards, sampled every 2 ft over the lot, plus basement digging.
export function earthwork(p) {
const step = 24, cell = step * step;
let cut = 0, fill = 0, dig = 0, disturbed = 0;
for (let y = step / 2; y < p.site.d; y += step) {
for (let x = step / 2; x < p.site.w; x += step) {
const e = groundAt(p.site, x, y), d = designAt(p, x, y);
if (d < e - 0.5) cut += (e - d) * cell;
else if (d > e + 0.5) fill += (d - e) * cell;
if (Math.abs(d - e) > 0.5) disturbed += cell;
}
}
const rows = [];
for (const s of p.structures) {
if (s.foundation !== 'basement' && s.foundation !== 'walkout') continue;
const bottom = levelBase(s, -1) - 4 - 10; // 4" slab, 10" footing
let v = 0;
const over = 24;
for (let y = s.y - over + 6; y < s.y + s.d + over; y += 12)
for (let x = s.x - over + 6; x < s.x + s.w + over; x += 12)
v += Math.max(0, designAt(p, x, y) - bottom) * 144;
dig += v;
rows.push({ name: s.name, yd3: v / 46656, depth: Math.max(0, designAt(p, s.x + s.w / 2, s.y + s.d / 2) - bottom) });
}
const Y = 46656;
return { cut: cut / Y, fill: fill / Y, dig: dig / Y, basements: rows, disturbedSqft: disturbed / 144 };
}
// Contour lines (marching squares) of the designed ground, every `interval` inches.
export function contours(p, interval = 12, step = 24) {
const nx = Math.ceil(p.site.w / step), ny = Math.ceil(p.site.d / step);
const Z = [];
for (let j = 0; j <= ny; j++) for (let i = 0; i <= nx; i++)
Z.push(designAt(p, Math.min(i * step, p.site.w), Math.min(j * step, p.site.d)));
const zmin = Math.min(...Z), zmax = Math.max(...Z);
const segs = [];
for (let lv = Math.ceil(zmin / interval) * interval; lv <= zmax; lv += interval) {
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
const P = [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]].map(([a, b]) =>
[Math.min(a * step, p.site.w), Math.min(b * step, p.site.d), Z[b * (nx + 1) + a]]);
const pts = [];
for (let k = 0; k < 4; k++) {
const A = P[k], B = P[(k + 1) % 4];
if ((A[2] < lv) !== (B[2] < lv)) {
const t = (lv - A[2]) / (B[2] - A[2]);
pts.push([A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t]);
}
}
if (pts.length >= 2) segs.push({ lv, a: pts[0], b: pts[1] });
if (pts.length === 4) segs.push({ lv, a: pts[2], b: pts[3] });
}
}
return { segs, zmin, zmax };
}
// ------------------------------------------------------------ roof geometry (shared by 3D + elevations)
export function ridgeAxis(s) {
if (s.roof.ridge === 'x' || s.roof.ridge === 'y') return s.roof.ridge;
return s.w >= s.d ? 'x' : 'y';
}
// Returns { roof: [poly], gables: [poly], ridgeZ } with poly = [[x,y,z], ...] in plan coords.
export function roofGeometry(s) {
const ax = ridgeAxis(s);
const L = ax === 'x' ? s.w : s.d, S = ax === 'x' ? s.d : s.w;
const P = s.roof.pitch / 12, o = s.roof.overhang, zp = plateZ(s);
// local u along the ridge, v across; map to plan
const M = ax === 'x' ? (u, v, z) => [s.x + u, s.y + v, z] : (u, v, z) => [s.x + S - v, s.y + u, z];
const roof = [], gables = [];
const ze = zp - o * P, zr = zp + (S / 2) * P;
const t = s.roof.type;
if (t === 'gable') {
roof.push([M(-o, -o, ze), M(L + o, -o, ze), M(L + o, S / 2, zr), M(-o, S / 2, zr)]);
roof.push([M(L + o, S + o, ze), M(-o, S + o, ze), M(-o, S / 2, zr), M(L + o, S / 2, zr)]);
gables.push([M(0, 0, zp), M(0, S, zp), M(0, S / 2, zr)]);
gables.push([M(L, S, zp), M(L, 0, zp), M(L, S / 2, zr)]);
return { roof, gables, ridgeZ: zr };
}
if (t === 'hip') {
const r1 = Math.min(S / 2, L / 2), r2 = Math.max(L - S / 2, L / 2);
roof.push([M(-o, -o, ze), M(L + o, -o, ze), M(r2, S / 2, zr), M(r1, S / 2, zr)]);
roof.push([M(L + o, S + o, ze), M(-o, S + o, ze), M(r1, S / 2, zr), M(r2, S / 2, zr)]);
roof.push([M(-o, S + o, ze), M(-o, -o, ze), M(r1, S / 2, zr)]);
roof.push([M(L + o, -o, ze), M(L + o, S + o, ze), M(r2, S / 2, zr)]);
return { roof, gables, ridgeZ: zr };
}
if (t === 'shed') {
const zt = zp + S * P;
roof.push([M(-o, -o, zp - o * P), M(L + o, -o, zp - o * P), M(L + o, S + o, zt + o * P), M(-o, S + o, zt + o * P)]);
gables.push([M(0, 0, zp), M(0, S, zp), M(0, S, zt)]);
gables.push([M(L, S, zp), M(L, 0, zp), M(L, S, zt)]);
gables.push([M(L, S, zp), M(0, S, zp), M(0, S, zt), M(L, S, zt)]);
return { roof, gables, ridgeZ: zt };
}
const zf = zp + 10;
roof.push([M(-o, -o, zf), M(L + o, -o, zf), M(L + o, S + o, zf), M(-o, S + o, zf)]);
return { roof, gables, ridgeZ: zf };
}
export function polyArea3D(poly) {
// area of a planar 3D polygon (Newell)
let nx = 0, ny = 0, nz = 0;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
nx += (a[1] - b[1]) * (a[2] + b[2]);
ny += (a[2] - b[2]) * (a[0] + b[0]);
nz += (a[0] - b[0]) * (a[1] + b[1]);
}
return Math.hypot(nx, ny, nz) / 2;
}
// ------------------------------------------------------------ materials list (rough)
export function materials(p) {
const out = [], shares = sharedWalls(p);
for (const s of p.structures) {
const walls = extWalls(s);
let wallArea = 0;
for (const k of 'NESW') wallArea += walls[k].len * (s.floors * s.wallH + (s.floors - 1) * FLOOR_DEPTH);
// a shared wall is inside, so it gets no siding on either building
for (const sh of shares) if (sh.keep === s || sh.drop === s)
wallArea -= (sh.hi - sh.lo) * sh.levels.filter(l => l >= 0).length * Math.min(sh.keep.wallH, sh.drop.wallH);
const rg = roofGeometry(s);
for (const g of rg.gables) wallArea += polyArea3D(g);
let openArea = 0;
for (const o of p.openings) if (o.s === s.id && 'NESW'.includes(o.wall)) openArea += o.w * o.h;
const net = sqft(wallArea - openArea);
const wain = s.ext.wainscot ? sqft(2 * (s.w + s.d) * s.ext.wainscot) : 0;
const roofArea = sqft(rg.roof.reduce((a, poly) => a + polyArea3D(poly), 0));
const item = { name: s.name, sidingName: SIDING[s.ext.mat], sidingSqft: net - wain, wainscotSqft: wain,
roofSqft: roofArea, roofSquares: roofArea / 100, roofName: ROOFING[s.roof.mat], floors: {} };
if (s.ext.mat === 'brick') item.bricks = Math.ceil((net - wain) * 6.75 * 1.05);
// Concrete: slab 4", footings 16x10 around the perimeter, basement walls 8".
const per = 2 * (s.w + s.d);
let conc = s.w * s.d * 4 + per * 16 * 10;
if (s.foundation === 'basement' || s.foundation === 'walkout') conc += per * 8 * (s.basementH + FLOOR_DEPTH);
if (s.foundation === 'crawl') conc = per * 16 * 10 + per * 8 * 36;
item.concreteYd3 = conc / 46656;
// doors by style and size
item.doors = {};
for (const o of p.openings) {
if (o.s !== s.id || (o.kind !== 'door' && o.kind !== 'garage')) continue;
const style = o.kind === 'garage' ? GARAGE_STYLES[o.style] || GARAGE_STYLES.panel : DOOR_STYLES[o.style] || DOOR_STYLES.panel;
const where = o.kind === 'garage' ? 'Garage door' : isExtWall(o) ? 'Outside door' : 'Inside door';
const k = `${where}, ${style.toLowerCase()}, ${fmtLen(o.w)} x ${fmtLen(o.h)}`;
item.doors[k] = (item.doors[k] || 0) + 1;
}
for (const r of p.rooms) if (r.s === s.id) item.floors[r.floor] = (item.floors[r.floor] || 0) + sqft(r.w * r.d);
// windows, roof edges and inside finishes
const mine = p.openings.filter(o => o.s === s.id);
item.windows = mine.filter(o => o.kind === 'window').length;
item.sliders = mine.filter(o => o.kind === 'slider').length;
item.egress = mine.filter(o => o.kind === 'egress').length;
item.wells = mine.filter(o => windowWell(p, s, o)).length;
const ed = extDetails(s), re = roofEdges(s), eaveLen = re.eaves.reduce((a, e) => a + e.len, 0);
item.fasciaFt = (eaveLen + re.rakes.reduce((a, e) => a + e.len, 0)) / 12;
item.soffitSqft = ed.soffit === 'none' ? 0 : sqft(eaveLen * re.o);
item.gutterFt = ed.gutter === 'none' || s.roof.type === 'flat' ? 0 : eaveLen / 12;
item.downspouts = item.gutterFt ? re.spouts.length : 0;
item.soffitName = SOFFITS[ed.soffit]; item.gutterName = GUTTERS[ed.gutter];
item.stairs = (p.stairs || []).filter(st => st.s === s.id).length;
out.push(item);
}
return out;
}
// Plain-language description for screen readers.
export function describe(p) {
const lines = [`Lot ${fmtLen(p.site.w)} wide by ${fmtLen(p.site.d)} deep.`];
for (const s of p.structures) {
lines.push(`${s.name}: ${fmtLen(s.w)} wide by ${fmtLen(s.d)} deep, ${s.floors} floor${s.floors > 1 ? 's' : ''}, ` +
`${FOUNDATIONS[s.foundation].toLowerCase()}, ${ROOF_TYPES[s.roof.type].toLowerCase()} roof, ${SIDING[s.ext.mat].toLowerCase()}. ` +
`Front wall is ${fmtLen(s.y)} back from the street, left wall ${fmtLen(s.x)} from the left lot line. ` +
`${Math.round(sqft(s.w * s.d) * s.floors)} square feet.`);
}
for (const sh of sharedWalls(p))
lines.push(`${sh.drop.name} is attached to the ${SIDE_NAMES[sh.keepSide].toLowerCase()} of ${sh.keep.name}, sharing ${fmtLen(sh.hi - sh.lo)} of wall.`);
const nf = (p.furniture || []).length;
if (nf) lines.push(`${nf} furniture and light item${nf > 1 ? 's' : ''} placed.`);
const nst = (p.stairs || []).length;
if (nst) lines.push(`${nst} stair${nst > 1 ? 's' : ''}.`);
const items = p.site.items || [];
const trees = items.filter(i => i.type === 'tree').length;
if (trees) lines.push(`${trees} tree${trees > 1 ? 's' : ''} on the lot.`);
for (const t of ['well', 'septic', 'drainfield']) if (items.some(i => i.type === t)) lines.push(`${{ well: 'Well', septic: 'Septic tank', drainfield: 'Septic drain field' }[t]} marked.`);
const tr = items.filter(i => i.type === 'trench');
if (tr.length) lines.push(`${tr.length} utility trench${tr.length > 1 ? 'es' : ''}, ${fmtLen(tr.reduce((a, t) => a + polyLen(t.pts), 0))} in all.`);
return lines.join(' ');
}
EOF
plan2d.js: draws the site and floor plans, and the editing tools.
cat > /root/build/plan2d.js <<'EOF'
// 2D plans: drawPlan() builds SVG markup in real inches (used on screen and for printing);
// PlanEditor adds pan/zoom and the drawing tools.
import { FT, uid, fmtLen, sqft, byId, extWalls, openingWall, levelsOf, contours, FLOORING, OPENINGS,
ridgeAxis, groundAt, sharedWalls, wallShare, wallOpenings, splitShared, windowWell, polyLen } from './model.js';
import { FURN, furnSvg, isLight, newFurniture, backToWall } from './furniture.js';
import { stairSvg, newStair, stairOk, stairLayout } from './stairs.js';
import { siteItemsSvg, slopeArrows, newSiteItem, UTILITIES } from './siteitems.js';
import { finishText } from './finishes.js';
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const P = (x, y) => `${x.toFixed(2)},${(-y).toFixed(2)}`;
const FLOOR_TINT = { oak: '#e9d3b0', walnut: '#cdb197', lvp: '#d9d6cf', tile: '#e3e7ea', tile12: '#e8e4dc',
carpet: '#dcd8e8', concrete: '#dcdcdc' };
function wallFrame(a, b) {
const dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy) || 1;
const d = [dx / len, dy / len];
return { d, n: [d[1], -d[0]], len }; // n = outward (right-hand) normal
}
function quad(a, d, n, t0, t1, out, inn) {
const pt = (t, k) => [a[0] + d[0] * t + n[0] * k, a[1] + d[1] * t + n[1] * k];
return [pt(t0, out), pt(t1, out), pt(t1, -inn), pt(t0, -inn)];
}
const poly = (pts, attrs) => `<polygon points="${pts.map(p => P(p[0], p[1])).join(' ')}" ${attrs}/>`;
const line = (a, b, attrs) => `<line x1="${a[0].toFixed(2)}" y1="${(-a[1]).toFixed(2)}" x2="${b[0].toFixed(2)}" y2="${(-b[1]).toFixed(2)}" ${attrs}/>`;
const text = (x, y, s, size, attrs = '') =>
`<text x="${x.toFixed(2)}" y="${(-y).toFixed(2)}" font-size="${size.toFixed(2)}" text-anchor="middle" dominant-baseline="middle" ${attrs}>${esc(s)}</text>`;
function dimLine(a, b, off, u, label) {
// dimension string parallel to a-b, offset by `off` along the outward normal
const { d, n, len } = wallFrame(a, b);
const A = [a[0] + n[0] * off, a[1] + n[1] * off], B = [b[0] + n[0] * off, b[1] + n[1] * off];
const tk = 5 * u;
let s = line(A, B, `stroke="#555" stroke-width="${u}"`);
for (const E of [A, B]) s += line([E[0] - (d[0] + n[0]) * tk, E[1] - (d[1] + n[1]) * tk], [E[0] + (d[0] + n[0]) * tk, E[1] + (d[1] + n[1]) * tk], `stroke="#555" stroke-width="${u}"`);
const mid = [(A[0] + B[0]) / 2 + n[0] * 9 * u, (A[1] + B[1]) / 2 + n[1] * 9 * u];
const ang = -Math.atan2(d[1], d[0]) * 180 / Math.PI;
const rot = ang > 90 || ang < -90 ? ang + 180 : ang;
s += `<text x="${mid[0].toFixed(2)}" y="${(-mid[1]).toFixed(2)}" font-size="${(11 * u).toFixed(2)}" text-anchor="middle" dominant-baseline="middle" fill="#333" transform="rotate(${rot.toFixed(1)} ${mid[0].toFixed(2)} ${(-mid[1]).toFixed(2)})">${esc(label || fmtLen(len))}</text>`;
return s;
}
function openingSymbol(o, w, u, sel) {
const { d, n } = wallFrame(w.a, w.b);
const t = w.t, a = w.a, h = sel ? '#d97a1a' : '#222';
const at = (s, k) => [a[0] + d[0] * s + n[0] * k, a[1] + d[1] * s + n[1] * k];
const out = w.ext ? 0 : t / 2, inn = w.ext ? t : t / 2;
let s = poly(quad(a, d, n, o.off, o.off + o.w, out, inn), `fill="#fff" stroke="none"`);
const sw = `stroke="${h}" stroke-width="${(sel ? 2.5 : 1.2) * u}" fill="none"`;
if (o.kind === 'window' || o.kind === 'egress') {
s += line(at(o.off, out), at(o.off + o.w, out), sw) + line(at(o.off, -inn), at(o.off + o.w, -inn), sw);
s += line(at(o.off, (out - inn) / 2), at(o.off + o.w, (out - inn) / 2), sw);
s += line(at(o.off, out), at(o.off, -inn), sw) + line(at(o.off + o.w, out), at(o.off + o.w, -inn), sw);
} else if (o.kind === 'slider') {
const m = (out - inn) / 2;
s += line(at(o.off, m + 1), at(o.off + o.w * 0.55, m + 1), sw) + line(at(o.off + o.w * 0.45, m - 1), at(o.off + o.w, m - 1), sw);
} else if (o.kind === 'garage') {
s += line(at(o.off, -inn - 2), at(o.off + o.w, -inn - 2), sw + ` stroke-dasharray="${6 * u} ${4 * u}"`);
s += line(at(o.off, out), at(o.off + o.w, out), sw);
} else if (o.style === 'barn' && !w.ext) {
// barn door: leaf hung on the wall face, rail running past the opening
s += poly(quad(a, d, n, o.off - 2, o.off + o.w + 2, -inn - 0.5, inn + 2), `fill="#fff" stroke="${h}" stroke-width="${(sel ? 2.5 : 1.2) * u}"`);
s += line(at(o.off - 4, -inn - 3), at(o.off + 2 * o.w + 4, -inn - 3), sw + ` stroke-dasharray="${5 * u} ${3 * u}"`);
} else {
// door: leaf + swing arc, opening into the building (double doors: two leaves from both jambs)
const leaves = o.style === 'double' ? [[o.off, o.w / 2, 1], [o.off + o.w, o.w / 2, -1]] : [[o.off, o.w, 1]];
for (const [h0, r, dir] of leaves) {
const hinge = at(h0, -inn), pts = [];
for (let i = 0; i <= 12; i++) {
const th = (i / 12) * Math.PI / 2;
pts.push([hinge[0] + dir * d[0] * r * Math.cos(th) - n[0] * r * Math.sin(th), hinge[1] + dir * d[1] * r * Math.cos(th) - n[1] * r * Math.sin(th)]);
}
s += line(hinge, pts[12], sw);
s += `<polyline points="${pts.map(p => P(p[0], p[1])).join(' ')}" stroke="${h}" stroke-width="${0.8 * u}" fill="none" stroke-dasharray="${3 * u} ${2 * u}"/>`;
}
}
// wide invisible hit target
s += poly(quad(a, d, n, o.off, o.off + o.w, out + 6, inn + 6), `fill="transparent" data-kind="op" data-id="${o.id}" style="cursor:ew-resize"`);
return s;
}
function extWallBands(p, s, lvl, u, sel, shares) {
const walls = extWalls(s);
let out = '';
const fill = sel === s.id ? '#6b4a2a' : '#3a3a3a';
for (const side of 'NESW') {
const w = walls[side];
const { d, n } = wallFrame(w.a, w.b);
const sh = wallShare(p, s, side, lvl, shares);
const ops = wallOpenings(p, s, side, lvl, sh);
const band = (t0, t1) => {
for (const [a, b, tag] of splitShared(t0, t1, sh))
if (tag !== 'skip') out += poly(quad(w.a, d, n, a, b, 0, 6), `fill="${fill}" data-kind="s" data-id="${s.id}"`);
};
let t = 0;
for (const o of ops) {
if (o.off > t) band(t, o.off);
t = Math.max(t, o.off + o.w);
}
if (t < w.len) band(t, w.len);
for (const o of ops) {
out += openingSymbol(o, { ...w, t: 6, ext: true }, u, sel === o.id);
const ww = o.kind === 'egress' && !o.mapped ? windowWell(p, s, o) : null;
if (ww) out += `<polyline points="${ww.corners.map(q => P(q[0], q[1])).join(' ')}" fill="${sel === o.id ? 'none' : 'rgba(150,155,160,.25)'}" stroke="${sel === o.id ? '#d97a1a' : '#555'}" stroke-width="${u}" stroke-dasharray="${4 * u} ${2 * u}"/>` +
text((ww.corners[2][0] + ww.corners[3][0]) / 2 - n[0] * 7 * u, (ww.corners[2][1] + ww.corners[3][1]) / 2 - n[1] * 7 * u, 'WELL', 7 * u, 'fill="#555" pointer-events="none"');
}
}
return out;
}
export function planBounds(p, view, only) {
if (view === 'site') return { x0: -4 * FT, y0: -11 * FT, x1: p.site.w + 8 * FT, y1: p.site.d + 2 * FT };
if (only) {
// tight crop for printing one building: dimension strings sit 4' out on the front and left
const s = byId(p.structures, only);
return { x0: s.x - 5 * FT, y0: s.y - 5 * FT, x1: s.x + s.w + FT, y1: s.y + s.d + 3 * FT };
}
const ss = p.structures.filter(s => levelsOf(s).includes(view));
if (!ss.length) return planBounds(p, 'site');
const m = 8 * FT;
return { x0: Math.min(...ss.map(s => s.x)) - m, y0: Math.min(...ss.map(s => s.y)) - m,
x1: Math.max(...ss.map(s => s.x + s.w)) + m, y1: Math.max(...ss.map(s => s.y + s.d)) + m };
}
// opts: u = real inches per screen/paper pixel, sel = selected id, print = true for paper
export function drawPlan(p, view, opts = {}) {
const u = opts.u || 1, sel = opts.sel;
let s = '';
const site = p.site;
if (!opts.only) s += `<rect x="0" y="${-site.d}" width="${site.w}" height="${site.d}" fill="${opts.print ? '#fff' : view === 'site' ? '#e4eddc' : '#f4f4f2'}" stroke="#2d5a27" stroke-width="${1.5 * u}"/>`;
if (view === 'site') {
const sb = site.setback;
s += `<rect x="${sb.side}" y="${-(site.d - sb.rear)}" width="${site.w - 2 * sb.side}" height="${site.d - sb.front - sb.rear}" fill="none" stroke="#2d5a27" stroke-width="${u}" stroke-dasharray="${8 * u} ${5 * u}"/>`;
const c = contours(p, 12);
for (const g of c.segs) {
const major = Math.round(g.lv / 12) % 5 === 0;
s += line(g.a, g.b, `stroke="${major ? '#8a6a3a' : '#b49a72'}" stroke-width="${(major ? 1.4 : 0.7) * u}"`);
}
// label one point per foot of contour
const seen = new Set();
for (const g of c.segs) {
const k = Math.round(g.lv / 12);
if (seen.has(k) || g.a[0] < 6 * FT) continue;
seen.add(k);
s += text(g.a[0], g.a[1], `${k}'`, 9 * u, `fill="#7a5a2a"`);
}
if (site.arrows !== false) s += slopeArrows(p, u);
s += siteItemsSvg(p, u, sel, 'under', opts.print);
s += text(site.w / 2, -8 * FT, 'STREET', 14 * u, `fill="#555" font-weight="700" letter-spacing="${4 * u}"`);
s += line([0, -3 * FT], [site.w, -3 * FT], `stroke="#999" stroke-width="${2 * u}"`);
// north arrow
const nx = site.w + 5 * FT, ny = site.d - 6 * FT;
s += `<polygon points="${P(nx, ny + 4 * FT)} ${P(nx - 1.4 * FT, ny)} ${P(nx + 1.4 * FT, ny)}" fill="#333"/>` + text(nx, ny - 1.6 * FT, 'N', 12 * u, 'font-weight="700"');
if (site.drive) {
for (const g of p.structures) {
const doors = p.openings.filter(o => o.s === g.id && o.kind === 'garage' && o.wall === 'S' && o.lvl === 0);
if (!doors.length) continue;
const x0 = g.x + Math.min(...doors.map(o => o.off)) - 18, x1 = g.x + Math.max(...doors.map(o => o.off + o.w)) + 18;
s += `<rect x="${x0}" y="${-g.y}" width="${x1 - x0}" height="${g.y + 3 * FT}" fill="#c8c8c4" stroke="#888" stroke-width="${u}" data-kind="drive"/>`;
s += text((x0 + x1) / 2, g.y / 2, 'Driveway', 10 * u, 'fill="#555"');
}
}
for (const st of p.structures) {
const isSel = sel === st.id;
s += `<rect x="${st.x}" y="${-(st.y + st.d)}" width="${st.w}" height="${st.d}" fill="${st.kind === 'garage' ? '#b9b4aa' : '#a9b6c4'}" stroke="${isSel ? '#d97a1a' : '#333'}" stroke-width="${(isSel ? 3 : 1.5) * u}" data-kind="s" data-id="${st.id}" style="cursor:move"/>`;
if (st.roof.type === 'gable' || st.roof.type === 'hip') {
const ax = ridgeAxis(st), inset = st.roof.type === 'hip' ? Math.min(st.w, st.d) / 2 : 0;
const a = ax === 'x' ? [st.x + inset, st.y + st.d / 2] : [st.x + st.w / 2, st.y + inset];
const b = ax === 'x' ? [st.x + st.w - inset, st.y + st.d / 2] : [st.x + st.w / 2, st.y + st.d - inset];
s += line(a, b, `stroke="#333" stroke-width="${u}" stroke-dasharray="${5 * u} ${3 * u}" pointer-events="none"`);
if (st.roof.type === 'hip') for (const [cx, cy] of [[st.x, st.y], [st.x + st.w, st.y], [st.x, st.y + st.d], [st.x + st.w, st.y + st.d]]) {
const e = (Math.abs(cx - a[0]) + Math.abs(cy - a[1]) < Math.abs(cx - b[0]) + Math.abs(cy - b[1])) ? a : b;
s += line([cx, cy], e, `stroke="#333" stroke-width="${0.7 * u}" pointer-events="none"`);
}
}
s += text(st.x + st.w / 2, st.y + st.d / 2 + 8 * u, st.name, 12 * u, 'font-weight="700" pointer-events="none"');
s += text(st.x + st.w / 2, st.y + st.d / 2 - 8 * u, `${fmtLen(st.w)} x ${fmtLen(st.d)}`, 10 * u, 'pointer-events="none"');
if (isSel && !opts.print) s += handle(st, u);
}
s += siteItemsSvg(p, u, sel, 'over', opts.print);
// distances to lot lines for the selected building
const st = byId(p.structures, sel);
if (st) {
s += dimLine([st.x, 0], [st.x, st.y], 0, u, fmtLen(st.y)).replace(/stroke="#555"/g, 'stroke="#d97a1a"');
s += dimLine([0, st.y + st.d / 2], [st.x, st.y + st.d / 2], 0, u, fmtLen(st.x)).replace(/stroke="#555"/g, 'stroke="#d97a1a"');
}
return s;
}
// ---- floor level
// one-building print sheets draw that building's own walls in full
const shares = opts.only ? [] : sharedWalls(p);
const furn = (p.furniture || []).filter(f => f.lvl === view && FURN[f.type]);
for (const st of p.structures) {
if (opts.only && st.id !== opts.only) continue;
if (!levelsOf(st).includes(view)) {
s += `<rect x="${st.x}" y="${-(st.y + st.d)}" width="${st.w}" height="${st.d}" fill="none" stroke="#bbb" stroke-width="${u}" stroke-dasharray="${4 * u} ${4 * u}"/>`;
continue;
}
s += `<rect x="${st.x}" y="${-(st.y + st.d)}" width="${st.w}" height="${st.d}" fill="#fff" stroke="none" data-kind="s" data-id="${st.id}"/>`;
for (const r of p.rooms.filter(r => r.s === st.id && r.lvl === view)) {
const x = st.x + r.x, y = st.y + r.y, isSel = sel === r.id;
s += `<rect x="${x}" y="${-(y + r.d)}" width="${r.w}" height="${r.d}" fill="${opts.print ? '#fff' : FLOOR_TINT[r.floor] || '#eee'}" stroke="${isSel ? '#d97a1a' : 'none'}" stroke-width="${3 * u}" data-kind="room" data-id="${r.id}" style="cursor:move"/>`;
s += text(x + r.w / 2, y + r.d / 2 + 13 * u, r.name, 12 * u, 'font-weight="700" pointer-events="none"');
s += text(x + r.w / 2, y + r.d / 2, `${fmtLen(r.w)} x ${fmtLen(r.d)}`, 10 * u, 'pointer-events="none"');
s += text(x + r.w / 2, y + r.d / 2 - 13 * u, `${Math.round(sqft(r.w * r.d))} sq ft · ${FLOORING[r.floor] || ''}`, 8.5 * u, 'fill="#555" pointer-events="none"');
const fin = finishText(st, r);
if (fin) s += text(x + r.w / 2, y + r.d / 2 - 24 * u, fin, 7.5 * u, 'fill="#6a5a3a" pointer-events="none"');
if (isSel && !opts.print) s += handle({ x, y, w: r.w, d: r.d }, u);
}
if (opts.furn !== false)
for (const f of furn) if (f.s === st.id && !isLight(f)) s += furnSvg(f, st.x + f.x, st.y + f.y, { u, sel: sel === f.id, print: opts.print });
// stairs going up from this floor first, then the dashed openings of stairs arriving from below
for (const sr of (p.stairs || []).filter(sr => sr.s === st.id).sort((a, b) => (a.lvl === view ? 0 : 1) - (b.lvl === view ? 0 : 1)))
s += stairSvg(sr, st, view, u, sel === sr.id, opts.print);
for (const w of p.walls.filter(w => w.s === st.id && w.lvl === view)) {
const a = [st.x + w.a[0], st.y + w.a[1]], b = [st.x + w.b[0], st.y + w.b[1]];
const { d, n, len } = wallFrame(a, b);
const ops = p.openings.filter(o => o.wall === w.id).sort((x, y) => x.off - y.off);
const isSel = sel === w.id, fill = isSel ? '#b0611a' : '#555';
let t = 0;
for (const o of ops) {
if (o.off > t) s += poly(quad(a, d, n, t, o.off, w.t / 2, w.t / 2), `fill="${fill}" data-kind="wall" data-id="${w.id}"`);
t = Math.max(t, o.off + o.w);
}
if (t < len) s += poly(quad(a, d, n, t, len, w.t / 2, w.t / 2), `fill="${fill}" data-kind="wall" data-id="${w.id}"`);
s += poly(quad(a, d, n, 0, len, w.t / 2 + 4, w.t / 2 + 4), `fill="transparent" data-kind="wall" data-id="${w.id}" style="cursor:move"`);
for (const o of ops) s += openingSymbol(o, { a, b, len, t: w.t, ext: false }, u, sel === o.id);
if (isSel && !opts.print) s += dimLine(a, b, -w.t / 2 - 14 * u, u);
}
s += extWallBands(p, st, view, u, sel, shares);
// wall-hung and ceiling items go on top of the walls
if (opts.lights !== false)
for (const f of furn) if (f.s === st.id && isLight(f)) s += furnSvg(f, st.x + f.x, st.y + f.y, { u, sel: sel === f.id, print: opts.print });
const W = extWalls(st);
// overall dimensions on the front and left, or the opposite side when that side is attached to another building
const attached = (side) => shares.some(sh => (sh.keep === st && sh.keepSide === side) || (sh.drop === st && sh.dropSide === side));
s += attached('S') ? dimLine(W.N.a, W.N.b, 4 * FT, u) : dimLine(W.S.a, W.S.b, 4 * FT, u);
s += attached('W') ? dimLine(W.E.a, W.E.b, 4 * FT, u) : dimLine(W.W.a, W.W.b, 4 * FT, u);
if (view === levelsOf(st)[0] || view === 0)
s += text(st.x + st.w / 2, st.y + st.d + 2.2 * FT, `${st.name} - ${view === -1 ? 'Basement' : 'Floor ' + (view + 1)}`, 12 * u, 'font-weight="700" fill="#333"');
if (sel === st.id && !opts.print) s += handle(st, u);
}
return s;
}
function handle(r, u) {
const sz = 10 * u;
return `<rect x="${r.x + r.w - sz / 2}" y="${-(r.y + r.d) - sz / 2}" width="${sz}" height="${sz}" fill="#d97a1a" stroke="#fff" stroke-width="${u}" data-kind="handle" style="cursor:nwse-resize"/>`;
}
// ------------------------------------------------------------ editor
export class PlanEditor {
constructor(svg, app) {
this.svg = svg; this.app = app;
this.view = 0; this.tool = 'select'; this.snap = 6;
this.u = 1; this.cx = 0; this.cy = 0;
this.drag = null; this.temp = null;
svg.addEventListener('pointerdown', e => this.down(e));
svg.addEventListener('pointermove', e => this.move(e));
svg.addEventListener('pointerup', e => this.up(e));
svg.addEventListener('dblclick', () => { if (this.temp?.type === 'trench') this.finishTrench(); this.temp = null; this.render(); });
svg.addEventListener('wheel', e => { e.preventDefault(); this.zoomAt(e.deltaY > 0 ? 1.15 : 1 / 1.15, this.pt(e)); }, { passive: false });
new ResizeObserver(() => this.render()).observe(svg);
}
get p() { return this.app.project; }
fit() {
const b = planBounds(this.p, this.view);
const r = this.svg.getBoundingClientRect();
this.u = Math.max((b.x1 - b.x0) / Math.max(r.width, 100), (b.y1 - b.y0) / Math.max(r.height, 100));
this.cx = (b.x0 + b.x1) / 2; this.cy = (b.y0 + b.y1) / 2;
this.render();
}
zoomAt(f, at) {
at = at || [this.cx, this.cy];
this.u *= f;
this.cx = at[0] + (this.cx - at[0]) * f; this.cy = at[1] + (this.cy - at[1]) * f;
this.render();
}
pt(e) {
const r = this.svg.getBoundingClientRect();
return [this.cx + (e.clientX - r.left - r.width / 2) * this.u, this.cy - (e.clientY - r.top - r.height / 2) * this.u];
}
sn(v) { return Math.round(v / this.snap) * this.snap; }
render() {
const r = this.svg.getBoundingClientRect();
if (!r.width) return;
const w = r.width * this.u, h = r.height * this.u;
this.svg.setAttribute('viewBox', `${this.cx - w / 2} ${-this.cy - h / 2} ${w} ${h}`);
let s = drawPlan(this.p, this.view, { u: this.u, sel: this.app.sel?.id });
const t = this.temp, u = this.u;
if (t?.type === 'wall' && t.b) s += line(t.a, t.b, `stroke="#d97a1a" stroke-width="${4.5}" stroke-linecap="square"`) + text((t.a[0] + t.b[0]) / 2, (t.a[1] + t.b[1]) / 2 + 12 * u, fmtLen(Math.hypot(t.b[0] - t.a[0], t.b[1] - t.a[1])), 12 * u, 'fill="#b0611a" font-weight="700"');
if (t?.type === 'rect' && t.b) {
const x = Math.min(t.a[0], t.b[0]), y = Math.min(t.a[1], t.b[1]), ww = Math.abs(t.b[0] - t.a[0]), dd = Math.abs(t.b[1] - t.a[1]);
s += `<rect x="${x}" y="${-(y + dd)}" width="${ww}" height="${dd}" fill="rgba(217,122,26,.15)" stroke="#d97a1a" stroke-width="${2 * u}"/>` + text(x + ww / 2, y + dd / 2, `${fmtLen(ww)} x ${fmtLen(dd)}`, 12 * u, 'fill="#b0611a" font-weight="700"');
}
if (t?.type === 'trench') {
const pts = t.b ? [...t.pts, t.b] : t.pts, col = UTILITIES[this.app.trenchUtil?.()]?.color || '#d02a20';
s += `<polyline points="${pts.map(q => P(q[0], q[1])).join(' ')}" fill="none" stroke="${col}" stroke-width="${2.5 * u}" stroke-dasharray="${8 * u} ${4 * u}"/>`;
if (pts.length > 1) s += text(pts.at(-1)[0], pts.at(-1)[1] + 14 * u, fmtLen(polyLen(pts)), 12 * u, `fill="${col}" font-weight="700"`);
}
if (t?.type === 'measure' && t.b) s += line(t.a, t.b, `stroke="#1a6ad9" stroke-width="${2 * u}" stroke-dasharray="${6 * u} ${3 * u}"`) + text((t.a[0] + t.b[0]) / 2, (t.a[1] + t.b[1]) / 2 + 12 * u, fmtLen(Math.hypot(t.b[0] - t.a[0], t.b[1] - t.a[1])), 13 * u, 'fill="#1a6ad9" font-weight="700"');
if (this.hover) s += text(this.hover[0], this.hover[1] - 16 * u, `${fmtLen(this.hover[0])}, ${fmtLen(this.hover[1])}`, 9 * u, 'fill="#777" pointer-events="none"');
this.svg.innerHTML = s;
}
structureAt(x, y) {
return this.p.structures.find(s => (this.view === 'site' || levelsOf(s).includes(this.view)) &&
x >= s.x - 1 && x <= s.x + s.w + 1 && y >= s.y - 1 && y <= s.y + s.d + 1);
}
nearestWall(x, y) {
let best = null, bd = Math.max(18, 14 * this.u);
for (const s of this.p.structures) {
if (!levelsOf(s).includes(this.view)) continue;
const lines = Object.entries(extWalls(s)).map(([k, w]) => ({ id: k, s, ...w, t: 6, ext: true }));
for (const w of this.p.walls.filter(w => w.s === s.id && w.lvl === this.view)) {
const a = [s.x + w.a[0], s.y + w.a[1]], b = [s.x + w.b[0], s.y + w.b[1]];
lines.push({ id: w.id, s, a, b, len: Math.hypot(b[0] - a[0], b[1] - a[1]), t: w.t });
}
for (const L of lines) {
const { d, n } = wallFrame(L.a, L.b);
const rx = x - L.a[0], ry = y - L.a[1];
const along = rx * d[0] + ry * d[1], across = rx * n[0] + ry * n[1] + (L.ext ? L.t / 2 : 0);
if (along < 0 || along > L.len) continue;
if (Math.abs(across) < bd) { bd = Math.abs(across); best = { ...L, along }; }
}
}
return best;
}
down(e) {
const [x, y] = this.pt(e);
const tgt = e.target.closest('[data-kind]');
this.svg.setPointerCapture(e.pointerId);
if (e.button === 1 || (this.tool === 'select' && !tgt)) {
this.drag = { type: 'pan', sx: e.clientX, sy: e.clientY, cx: this.cx, cy: this.cy };
if (!tgt && e.button === 0) this.app.select(null);
return;
}
const sx = this.sn(x), sy = this.sn(y);
if (this.tool === 'select') {
const kind = tgt.dataset.kind, id = tgt.dataset.id;
if (kind === 'handle') {
const o = this.app.sel;
this.drag = { type: 'resize', o, w: o.w, d: o.d, x0: x, y0: y };
return;
}
const list = { s: this.p.structures, room: this.p.rooms, wall: this.p.walls, op: this.p.openings, furn: this.p.furniture, stair: this.p.stairs, site: this.p.site.items }[kind];
const o = list && byId(list, id);
if (!o) return;
this.app.select(o, kind);
if (kind === 'op') this.drag = { type: 'op', o, off: o.off, x0: x, y0: y };
else if (o.pts) this.drag = { type: 'pts', o, pts: o.pts.map(q => [...q]), x0: x, y0: y };
else if (kind === 'wall') this.drag = { type: 'wall', o, a: [...o.a], b: [...o.b], x0: x, y0: y };
else this.drag = { type: 'move', o, ox: o.x, oy: o.y, x0: x, y0: y };
return;
}
if (this.tool === 'wall') {
if (!this.temp) { this.temp = { type: 'wall', a: [sx, sy], b: null }; return; }
this.finishWall();
return;
}
if (this.tool === 'room') { this.temp = { type: 'rect', a: [sx, sy], b: [sx, sy] }; this.drag = { type: 'rect' }; return; }
if (this.tool === 'measure') { this.temp = { type: 'measure', a: [x, y], b: [x, y] }; this.drag = { type: 'measure' }; return; }
if (OPENINGS[this.tool]) this.placeOpening(x, y);
if (this.tool === 'furn') this.placeFurniture(sx, sy);
if (this.tool === 'stairs') this.placeStairs(sx, sy);
if (this.tool === 'tree') this.placeSite('tree', sx, sy);
if (this.tool === 'trench') {
if (this.view !== 'site') { this.app.say('Trenches go on the site plan. Pick Site plan in the Showing list.'); return; }
if (!this.temp) { this.temp = { type: 'trench', pts: [[sx, sy]], b: null }; this.app.say('Trench started. Click each bend, double-click to finish.'); return; }
const last = this.temp.pts.at(-1);
if (last[0] !== sx || last[1] !== sy) this.temp.pts.push([sx, sy]);
}
}
placeStairs(x, y) {
const s = this.structureAt(x, y);
if (!s || this.view === 'site') { this.app.say('Click inside a building on a floor.'); return; }
const st = newStair(s, this.view, x - s.x, y - s.y, this.app.stairOpts?.());
if (!stairOk(s, st)) { this.app.say(`${s.name} has no floor above ${this.view === -1 ? 'the basement' : 'this one'}. Set Floors to 2 or more, or add a basement and start on it.`); return; }
this.p.stairs.push(st);
this.app.select(st, 'stair');
const L = stairLayout(st, s);
this.app.changed(`Stairs added: ${L.n} risers of ${fmtLen(L.r, { words: true })}, taking up ${fmtLen(L.box.x1 - L.box.x0, { words: true })} by ${fmtLen(L.box.y1 - L.box.y0, { words: true })}. Change the shape and direction in the properties panel.`);
}
placeSite(type, x, y) {
if (this.view !== 'site') { this.app.say('Trees, the well and septic go on the site plan. Pick Site plan in the Showing list.'); return; }
const it = newSiteItem(type, x, y, this.app.siteOpts?.() || {});
this.p.site.items.push(it);
this.app.select(it, 'site');
this.app.changed(`${type === 'tree' ? 'Tree' : type} added ${fmtLen(x, { words: true })} from the left lot line, ${fmtLen(y, { words: true })} from the street.`);
}
finishTrench() {
const t = this.temp;
this.temp = null;
if (!t || t.pts.length < 2) { this.app.say('A trench needs at least two points.'); return; }
const it = newSiteItem('trench', 0, 0, { util: this.app.trenchUtil?.(), pts: t.pts });
this.p.site.items.push(it);
this.app.select(it, 'site');
this.app.changed(`${UTILITIES[it.util].name} trench added, ${fmtLen(polyLen(it.pts), { words: true })} long.`);
}
move(e) {
const [x, y] = this.pt(e);
this.hover = this.tool !== 'select' ? [this.sn(x), this.sn(y)] : null;
const d = this.drag;
if (this.temp?.type === 'trench') this.temp.b = [this.sn(x), this.sn(y)];
if (this.temp?.type === 'wall') {
let b = [this.sn(x), this.sn(y)];
const a = this.temp.a;
if (!e.shiftKey) { if (Math.abs(b[0] - a[0]) > Math.abs(b[1] - a[1])) b[1] = a[1]; else b[0] = a[0]; }
this.temp.b = b;
}
if (!d) { if (this.tool !== 'select') this.render(); return; }
if (d.type === 'pan') {
this.cx = d.cx - (e.clientX - d.sx) * this.u; this.cy = d.cy + (e.clientY - d.sy) * this.u;
} else if (d.type === 'move') {
d.o.x = this.sn(d.ox + x - d.x0); d.o.y = this.sn(d.oy + y - d.y0); d.moved = true;
} else if (d.type === 'pts') {
const dx = this.sn(x - d.x0), dy = this.sn(y - d.y0);
d.o.pts = d.pts.map(q => [q[0] + dx, q[1] + dy]); d.moved = true;
} else if (d.type === 'resize') {
d.o.w = Math.max(24, this.sn(d.w + x - d.x0)); d.o.d = Math.max(24, this.sn(d.d + y - d.y0)); d.moved = true;
} else if (d.type === 'wall') {
const dx = this.sn(x - d.x0), dy = this.sn(y - d.y0);
d.o.a = [d.a[0] + dx, d.a[1] + dy]; d.o.b = [d.b[0] + dx, d.b[1] + dy]; d.moved = true;
} else if (d.type === 'op') {
const w = openingWall(this.p, d.o);
if (w) {
const { d: dir } = wallFrame(w.a, w.b);
const delta = (x - d.x0) * dir[0] + (y - d.y0) * dir[1];
d.o.off = Math.max(0, Math.min(w.len - d.o.w, this.sn(d.off + delta))); d.moved = true;
}
} else if (d.type === 'rect' || d.type === 'measure') {
this.temp.b = d.type === 'rect' ? [this.sn(x), this.sn(y)] : [x, y];
}
this.render();
}
up(e) {
const d = this.drag; this.drag = null;
if (!d) return;
if (d.moved) this.app.changed(this.describeEdit(d));
if (d.type === 'rect') this.finishRoom();
if (d.type === 'measure' && this.temp) this.app.say(`Measured ${fmtLen(Math.hypot(this.temp.b[0] - this.temp.a[0], this.temp.b[1] - this.temp.a[1]), { words: true })}`);
this.render();
}
describeEdit(d) {
const o = d.o;
if (d.type === 'pts') return 'Trench moved';
if (d.type === 'move' && o.type) return `Moved: ${fmtLen(o.x)} from left lot line, ${fmtLen(o.y)} from street`;
if (d.type === 'move' && o.shape) return `Stairs moved: ${fmtLen(o.x)} from the building's left, ${fmtLen(o.y)} from its front`;
if (d.type === 'move' && o.kind) return `${o.name} moved: ${fmtLen(o.x)} from left lot line, ${fmtLen(o.y)} from street`;
if (d.type === 'move') return `${o.name} moved`;
if (d.type === 'resize') return `Size now ${fmtLen(o.w)} by ${fmtLen(o.d)}`;
if (d.type === 'op') return `Opening moved to ${fmtLen(o.off)} from the wall start`;
return 'Wall moved';
}
finishWall() {
const t = this.temp;
if (!t?.b || (t.a[0] === t.b[0] && t.a[1] === t.b[1])) return;
const s = this.structureAt(t.a[0], t.a[1]) || this.structureAt(t.b[0], t.b[1]);
if (!s || this.view === 'site') { this.app.say('Start the wall inside a building on this floor.'); this.temp = null; this.render(); return; }
const w = { id: uid(), s: s.id, lvl: this.view, a: [t.a[0] - s.x, t.a[1] - s.y], b: [t.b[0] - s.x, t.b[1] - s.y], t: 4.5, color: '' };
this.p.walls.push(w);
this.temp = { type: 'wall', a: t.b, b: null };
this.app.changed(`Wall added, ${fmtLen(Math.hypot(t.b[0] - t.a[0], t.b[1] - t.a[1]), { words: true })} long. Click again to keep drawing, double click to stop.`);
}
finishRoom() {
const t = this.temp; this.temp = null;
if (!t?.b) return;
const x = Math.min(t.a[0], t.b[0]), y = Math.min(t.a[1], t.b[1]), w = Math.abs(t.b[0] - t.a[0]), d = Math.abs(t.b[1] - t.a[1]);
if (w < 24 || d < 24) return;
const s = this.structureAt(x + w / 2, y + d / 2);
if (!s || this.view === 'site') { this.app.say('Draw the room inside a building on this floor.'); return; }
this.app.addRoom(s, this.view, x - s.x, y - s.y, w, d, this.app.ui.roomWalls?.checked !== false);
}
placeFurniture(x, y) {
const s = this.structureAt(x, y);
if (!s || this.view === 'site') { this.app.say('Click inside a building on this floor.'); return; }
const type = this.app.furnType();
const f = newFurniture(type, s, this.view, x - s.x, y - s.y);
const wall = FURN[type].wall ? backToWall(this.p, f, FURN[type].mount === 'wall' ? 36 : 24) : null;
this.p.furniture.push(f);
this.app.select(f, 'furn');
this.app.changed(`${f.name} added${wall ? ' against the ' + wall : ''}. Press R to turn it.`);
}
placeOpening(x, y) {
const w = this.nearestWall(x, y);
if (!w) { this.app.say('Click on a wall to place it.'); return; }
const def = OPENINGS[this.tool];
if ((this.tool === 'garage' || this.tool === 'egress') && !w.ext) { this.app.say(`${def.name.split(' (')[0]}s go on outside walls.`); return; }
const width = Math.min(def.w, w.len);
const o = { id: uid(), s: w.s.id, lvl: this.view, wall: w.id, kind: this.tool,
off: Math.max(0, Math.min(w.len - width, this.sn(w.along - width / 2))), w: width, h: def.h, sill: def.sill,
style: this.tool === 'window' ? 'grid' : this.tool === 'egress' ? 'casement' : 'panel' };
this.p.openings.push(o);
this.app.select(o, 'op');
this.app.changed(`${def.name} added${o.kind === 'egress' && this.view !== -1 ? '. Egress windows are meant for basements.' : ''}`);
}
}
EOF
textures.js: draws every siding, roofing and flooring texture on a canvas at real size.
cat > /root/build/textures.js <<'EOF'
// Procedural material textures drawn on a canvas at real-world size.
// Each texture covers `inU` x `inV` inches and tiles seamlessly.
import * as THREE from 'three';
const cache = new Map();
const PX = 512;
function rng(seed) {
return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
function hex(c) { const n = parseInt(c.replace('#', ''), 16); return [n >> 16 & 255, n >> 8 & 255, n & 255]; }
function shade([r, g, b], k) { const f = (v) => Math.max(0, Math.min(255, Math.round(v * k))); return `rgb(${f(r)},${f(g)},${f(b)})`; }
function noise(ctx, w, h, amt, r, size = 1) {
const img = ctx.getImageData(0, 0, w, h), d = img.data;
for (let y = 0; y < h; y += size) for (let x = 0; x < w; x += size) {
const n = (r() - 0.5) * amt;
for (let yy = y; yy < Math.min(h, y + size); yy++) for (let xx = x; xx < Math.min(w, x + size); xx++) {
const i = (yy * w + xx) * 4; d[i] += n; d[i + 1] += n; d[i + 2] += n;
}
}
ctx.putImageData(img, 0, 0);
}
const RECIPES = {
// ---------------- siding
vinyl(ctx, c, r, W, H) { // 4" exposure, shadow line under each course
const n = 24, ch = H / n;
for (let i = 0; i < n; i++) {
const y = i * ch, g = ctx.createLinearGradient(0, y, 0, y + ch);
g.addColorStop(0, shade(c, 1.06)); g.addColorStop(0.85, shade(c, 0.96)); g.addColorStop(1, shade(c, 0.62));
ctx.fillStyle = g; ctx.fillRect(0, y, W, ch);
}
noise(ctx, W, H, 8, r);
},
fibercement(ctx, c, r, W, H) { // 7" exposure with wood grain
const n = 12, ch = H / n;
for (let i = 0; i < n; i++) {
const y = i * ch, g = ctx.createLinearGradient(0, y, 0, y + ch);
g.addColorStop(0, shade(c, 1.03)); g.addColorStop(0.9, shade(c, 0.97)); g.addColorStop(1, shade(c, 0.55));
ctx.fillStyle = g; ctx.fillRect(0, y, W, ch);
ctx.strokeStyle = shade(c, 0.9); ctx.lineWidth = 1;
for (let k = 0; k < 6; k++) { const yy = y + r() * ch * 0.85; ctx.globalAlpha = 0.25; ctx.beginPath(); ctx.moveTo(0, yy); for (let x = 0; x <= W; x += 32) ctx.lineTo(x, yy + Math.sin(x / 40 + k) * 1.5); ctx.stroke(); }
ctx.globalAlpha = 1;
}
noise(ctx, W, H, 6, r);
},
board(ctx, c, r, W, H) { // 12" boards with 2" battens, vertical
ctx.fillStyle = shade(c, 0.97); ctx.fillRect(0, 0, W, H);
const n = 6, bw = W / n, bat = bw / 6;
for (let i = 0; i < n; i++) {
const x = i * bw - bat / 2;
ctx.fillStyle = shade(c, 0.7); ctx.fillRect(x + bat, 0, 3, H);
const g = ctx.createLinearGradient(x, 0, x + bat, 0);
g.addColorStop(0, shade(c, 1.1)); g.addColorStop(1, shade(c, 0.92));
ctx.fillStyle = g; ctx.fillRect(x, 0, bat, H);
}
noise(ctx, W, H, 7, r);
},
shake(ctx, c, r, W, H) { // 7" courses of random-width shingles
const n = 12, ch = H / n;
for (let i = 0; i < n; i++) {
let x = -r() * 40;
while (x < W) {
const w = 22 + r() * 50, k = 0.8 + r() * 0.35;
const g = ctx.createLinearGradient(0, i * ch, 0, (i + 1) * ch);
g.addColorStop(0, shade(c, k * 0.92)); g.addColorStop(0.92, shade(c, k)); g.addColorStop(1, shade(c, k * 0.5));
ctx.fillStyle = g; ctx.fillRect(x, i * ch, w, ch);
ctx.fillStyle = shade(c, 0.45); ctx.fillRect(x, i * ch, 2, ch);
for (let k2 = 0; k2 < 4; k2++) { ctx.fillStyle = shade(c, k * 0.88); ctx.fillRect(x + r() * w, i * ch, 1, ch); }
x += w;
}
}
noise(ctx, W, H, 10, r);
},
// modular brick 7 5/8 x 2 1/4, 3/8 mortar, 32 courses in 84". Running bond (tile 80"), stack bond (80"),
// or Flemish bond: stretcher + header in every course, headers centred over stretchers (tile 72")
brick(ctx, c, r, W, H, bond = 'running', inU = 80) {
ctx.fillStyle = '#b9b2a6'; ctx.fillRect(0, 0, W, H);
const rows = 32, bh = H / rows, m = bh * 0.143, k = W / inU, unit = bond === 'flemish' ? [8, 4] : [8];
for (let j = 0; j < rows; j++) {
let x = -12 * k + (bond === 'running' ? (j % 2) * 4 * k : bond === 'flemish' ? (j % 2) * 6 * k : 0), i = 0;
while (x < W) {
const bw = unit[i++ % unit.length] * k, kk = (0.78 + r() * 0.4) * (bw < 6 * k ? 0.88 : 1);
ctx.fillStyle = shade(c, kk); ctx.fillRect(x + m / 2, j * bh + m / 2, bw - m, bh - m);
ctx.fillStyle = shade(c, kk * 0.8); ctx.fillRect(x + m / 2, j * bh + bh - m, bw - m, m / 2);
x += bw;
}
}
noise(ctx, W, H, 18, r);
},
// herringbone: columns of bricks slanting 45 degrees, alternating direction (bricks or tile)
herring(ctx, c, r, W, H, grout = '#b9b2a6') {
ctx.fillStyle = grout; ctx.fillRect(0, 0, W, H);
const cols = 8, cw = W / cols, p = H / 12;
ctx.strokeStyle = grout; ctx.lineWidth = Math.max(1, p * 0.12);
for (let i = 0; i < cols; i++) {
const x0 = i * cw, dir = i % 2 ? 1 : -1;
for (let j = -3; j < 15; j++) {
const y = j * p;
ctx.fillStyle = shade(c, 0.8 + r() * 0.35);
ctx.beginPath(); ctx.moveTo(x0, y); ctx.lineTo(x0 + cw, y + dir * cw); ctx.lineTo(x0 + cw, y + p + dir * cw); ctx.lineTo(x0, y + p); ctx.closePath();
ctx.fill(); ctx.stroke();
}
}
noise(ctx, W, H, 12, r);
},
soffit(ctx, c, r, W, H) { // vented vinyl soffit: 12" panels, grooves every 4", perforated centre strip
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
const k = W / 48;
for (let x = 0; x < W; x += 4 * k) { ctx.fillStyle = shade(c, x % (12 * k) < 1 ? 0.6 : 0.82); ctx.fillRect(x, 0, Math.max(1, 0.25 * k), H); }
ctx.fillStyle = shade(c, 0.45);
for (let x = 6 * k; x < W; x += 12 * k) for (let y = 0; y < H; y += 1.2 * k) { ctx.fillRect(x - 1.2 * k, y, 0.4 * k, 0.4 * k); ctx.fillRect(x + 0.8 * k, y + 0.6 * k, 0.4 * k, 0.4 * k); }
noise(ctx, W, H, 4, r);
},
wpStripe(ctx, c, r, W, H) {
const n = 8, sw = W / n;
for (let i = 0; i < n; i++) { ctx.fillStyle = shade(c, i % 2 ? 0.84 : 1.02); ctx.fillRect(i * sw, 0, sw, H); ctx.fillStyle = shade(c, 0.7); ctx.fillRect(i * sw, 0, Math.max(1, sw * 0.04), H); }
noise(ctx, W, H, 3, r);
},
wpDamask(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 1.03); ctx.fillRect(0, 0, W, H);
ctx.fillStyle = shade(c, 0.86);
for (const [cx, cy] of [[W / 4, H / 4], [3 * W / 4, 3 * H / 4], [3 * W / 4, -H / 4], [-W / 4, 3 * H / 4], [W / 4, 5 * H / 4], [5 * W / 4, H / 4]]) {
const s = W / 8;
ctx.beginPath(); ctx.ellipse(cx, cy, s * 0.55, s * 1.3, 0, 0, Math.PI * 2); ctx.fill();
for (const sx of [-1, 1]) { ctx.beginPath(); ctx.ellipse(cx + sx * s * 0.9, cy + s * 0.2, s * 0.35, s * 0.8, sx * 0.6, 0, Math.PI * 2); ctx.fill(); }
ctx.beginPath(); ctx.arc(cx, cy - s * 1.6, s * 0.3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy + s * 1.55, s * 0.22, 0, Math.PI * 2); ctx.fill();
}
noise(ctx, W, H, 3, r);
},
wpFloral(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 1.04); ctx.fillRect(0, 0, W, H);
const n = 4, g = W / n;
for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) {
const cx = i * g + (j % 2 ? g / 2 : 0) + g / 4, cy = j * g + g / 2, s = g * 0.13;
ctx.fillStyle = '#7d9a6a'; ctx.beginPath(); ctx.ellipse(cx + s * 1.6, cy + s * 1.2, s * 0.9, s * 0.4, 0.7, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = shade([196, 120, 130], 0.9 + r() * 0.2);
for (let k = 0; k < 5; k++) { const a = k / 5 * Math.PI * 2; ctx.beginPath(); ctx.arc(cx + Math.cos(a) * s, cy + Math.sin(a) * s, s * 0.7, 0, Math.PI * 2); ctx.fill(); }
ctx.fillStyle = '#e9c45a'; ctx.beginPath(); ctx.arc(cx, cy, s * 0.5, 0, Math.PI * 2); ctx.fill();
}
noise(ctx, W, H, 3, r);
},
wpCheck(ctx, c, r, W, H) {
ctx.fillStyle = '#f6f4ef'; ctx.fillRect(0, 0, W, H);
const n = 8, g = W / n;
ctx.globalAlpha = 0.45; ctx.fillStyle = shade(c, 0.9);
for (let i = 0; i < n; i += 2) { ctx.fillRect(i * g, 0, g, H); ctx.fillRect(0, i * g, W, g); }
ctx.globalAlpha = 1; noise(ctx, W, H, 4, r);
},
wpGrass(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 1400; i++) { ctx.fillStyle = shade(c, 0.78 + r() * 0.35); ctx.fillRect(r() * W, r() * H, 10 + r() * 60, 1); }
noise(ctx, W, H, 8, r);
},
subway(ctx, c, r, W, H) { // 3 x 6 glazed tile, 1/8" white grout, running bond
ctx.fillStyle = '#e9e6df'; ctx.fillRect(0, 0, W, H);
const k = W / 48, g = 0.15 * k;
for (let j = 0; j < 16; j++) for (let x = (j % 2) * 3 * k - 6 * k; x < W; x += 6 * k) {
const kk = 0.95 + r() * 0.08, y = j * 3 * k;
ctx.fillStyle = shade(c, kk); ctx.fillRect(x + g, y + g, 6 * k - 2 * g, 3 * k - 2 * g);
ctx.fillStyle = shade(c, kk * 1.06); ctx.fillRect(x + g, y + g, 6 * k - 2 * g, 0.5 * k);
}
},
hex(ctx, c, r, W, H) { // flat-top hexagons, 3" across corners
ctx.fillStyle = '#e9e6df'; ctx.fillRect(0, 0, W, H);
const k = W / 45, R = 1.5 * k, h = R * Math.sqrt(3) / 2;
for (let i = -1; i <= 10; i++) for (let j = -1; j <= 33; j++) {
const cx = i * 3 * R + (j % 2 ? 1.5 * R : 0), cy = j * h;
ctx.fillStyle = shade(c, 0.93 + r() * 0.1); ctx.beginPath();
for (let a = 0; a < 6; a++) { const t = a * Math.PI / 3; ctx.lineTo(cx + Math.cos(t) * R * 0.94, cy + Math.sin(t) * R * 0.94); }
ctx.fill();
}
},
mosaic(ctx, c, r, W, H) { // 1" glass squares in mixed shades
ctx.fillStyle = '#e9e6df'; ctx.fillRect(0, 0, W, H);
const n = 24, g = W / n;
for (let j = 0; j < n; j++) for (let i = 0; i < n; i++) { ctx.fillStyle = shade(c, 0.7 + r() * 0.5); ctx.fillRect(i * g + g * 0.07, j * g + g * 0.07, g * 0.86, g * 0.86); }
},
bead(ctx, c, r, W, H) { // beadboard: tongue and groove boards with a bead every 2"
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
const k = W / 24;
for (let x = 0; x < W; x += 2 * k) { ctx.fillStyle = shade(c, 0.72); ctx.fillRect(x, 0, Math.max(1, 0.18 * k), H); ctx.fillStyle = shade(c, 1.08); ctx.fillRect(x + 0.25 * k, 0, Math.max(1, 0.15 * k), H); }
noise(ctx, W, H, 4, r);
},
galv(ctx, c, r, W, H) { // corrugated galvanized steel, 2 2/3" corrugations running across
const n = 18;
for (let j = 0; j < n; j++) {
const y = j * H / n, g = ctx.createLinearGradient(0, y, 0, y + H / n);
g.addColorStop(0, shade(c, 0.75)); g.addColorStop(0.5, shade(c, 1.15)); g.addColorStop(1, shade(c, 0.75));
ctx.fillStyle = g; ctx.fillRect(0, y, W, H / n);
}
noise(ctx, W, H, 14, r, 3);
},
gravel(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 0.7); ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 2600; i++) { ctx.fillStyle = shade(c, 0.6 + r() * 0.7); ctx.beginPath(); ctx.ellipse(r() * W, r() * H, 2 + r() * 4, 1.5 + r() * 3, r() * 3, 0, Math.PI * 2); ctx.fill(); }
},
stone(ctx, c, r, W, H) { // ashlar stone veneer
ctx.fillStyle = '#8f8a82'; ctx.fillRect(0, 0, W, H);
let y = 0;
while (y < H) {
const h = 24 + r() * 46;
let x = -r() * 60;
while (x < W) {
const w = 40 + r() * 90, k = 0.7 + r() * 0.5;
ctx.fillStyle = shade(c, k);
ctx.beginPath(); ctx.roundRect(x + 3, y + 3, w - 6, Math.min(h, H - y) - 6, 8); ctx.fill();
ctx.fillStyle = shade(c, k * 1.12); ctx.fillRect(x + 8, y + 6, w - 20, 3);
x += w;
}
y += h;
}
noise(ctx, W, H, 26, r, 2);
},
stucco(ctx, c, r, W, H) { ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H); noise(ctx, W, H, 26, r, 2); noise(ctx, W, H, 10, r); },
// ---------------- roofing
asphalt(ctx, c, r, W, H) { // 3-tab-ish architectural shingles, 5" exposure
const n = 12, ch = H / n;
for (let i = 0; i < n; i++) {
let x = (i % 2) * 30 - 60;
while (x < W) {
const w = 40 + r() * 50, k = 0.75 + r() * 0.45;
ctx.fillStyle = shade(c, k); ctx.fillRect(x, i * ch, w, ch);
ctx.fillStyle = shade(c, 0.35); ctx.fillRect(x, i * ch, 2, ch);
x += w;
}
ctx.fillStyle = shade(c, 0.3); ctx.fillRect(0, (i + 1) * ch - 3, W, 3);
}
noise(ctx, W, H, 40, r);
},
metal(ctx, c, r, W, H) { // standing seam every 16"
const n = 6, bw = W / n;
for (let i = 0; i < n; i++) {
const g = ctx.createLinearGradient(i * bw, 0, (i + 1) * bw, 0);
g.addColorStop(0, shade(c, 1.25)); g.addColorStop(0.04, shade(c, 0.85)); g.addColorStop(0.5, shade(c, 1)); g.addColorStop(0.96, shade(c, 0.95)); g.addColorStop(1, shade(c, 0.6));
ctx.fillStyle = g; ctx.fillRect(i * bw, 0, bw, H);
}
noise(ctx, W, H, 4, r);
},
// ---------------- floors
wood(ctx, c, r, W, H, plank) {
const n = Math.round(96 / plank), pw = W / n;
for (let i = 0; i < n; i++) {
let y = -r() * H;
while (y < H) {
const len = H * (0.25 + r() * 0.5), k = 0.82 + r() * 0.3;
ctx.fillStyle = shade(c, k); ctx.fillRect(i * pw, y, pw, len);
ctx.strokeStyle = shade(c, k * 0.82); ctx.lineWidth = 1;
for (let g = 0; g < 5; g++) { const gx = i * pw + r() * pw; ctx.globalAlpha = 0.4; ctx.beginPath(); ctx.moveTo(gx, y); ctx.bezierCurveTo(gx + 3, y + len / 3, gx - 3, y + 2 * len / 3, gx + 1, y + len); ctx.stroke(); }
ctx.globalAlpha = 1;
ctx.fillStyle = shade(c, 0.5); ctx.fillRect(i * pw, y, pw, 1.5);
y += len;
}
ctx.fillStyle = shade(c, 0.55); ctx.fillRect(i * pw, 0, 1.5, H);
}
noise(ctx, W, H, 6, r);
},
tile(ctx, c, r, W, H, tw, th) {
ctx.fillStyle = '#bdb8ae'; ctx.fillRect(0, 0, W, H);
const nx = Math.round(96 / tw), ny = Math.round(96 / th), cw = W / nx, chh = H / ny, gr = 2;
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
const k = 0.94 + r() * 0.1;
ctx.fillStyle = shade(c, k); ctx.fillRect(i * cw + gr, j * chh + gr, cw - gr * 2, chh - gr * 2);
for (let v = 0; v < 3; v++) { ctx.strokeStyle = shade(c, 0.86); ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.moveTo(i * cw + r() * cw, j * chh); ctx.quadraticCurveTo(i * cw + r() * cw, j * chh + chh / 2, i * cw + r() * cw, (j + 1) * chh); ctx.stroke(); ctx.globalAlpha = 1; }
}
noise(ctx, W, H, 5, r);
},
carpet(ctx, c, r, W, H) { ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H); noise(ctx, W, H, 30, r); noise(ctx, W, H, 12, r, 3); },
concrete(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H); noise(ctx, W, H, 14, r, 4); noise(ctx, W, H, 10, r);
ctx.strokeStyle = shade(c, 0.7); ctx.lineWidth = 2; ctx.strokeRect(0, 0, W, H); // control joints every 8 ft
},
paint(ctx, c, r, W, H) { ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H); noise(ctx, W, H, 4, r); },
grass(ctx, c, r, W, H) {
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
for (let i = 0; i < 9000; i++) { ctx.fillStyle = shade(c, 0.6 + r() * 0.8); ctx.fillRect(r() * W, r() * H, 1, 2 + r() * 3); }
noise(ctx, W, H, 18, r, 8);
},
dirt(ctx, c, r, W, H) { ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H); noise(ctx, W, H, 40, r, 3); noise(ctx, W, H, 20, r); },
garagedoor(ctx, c, r, W, H) { // 4 horizontal panel sections, raised panels
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
const rows = 4, cols = 8, rh = H / rows, cw = W / cols;
for (let j = 0; j < rows; j++) {
ctx.fillStyle = shade(c, 0.6); ctx.fillRect(0, j * rh, W, 2);
for (let i = 0; i < cols; i++) {
ctx.fillStyle = shade(c, 0.88); ctx.fillRect(i * cw + 6, j * rh + 10, cw - 12, rh - 20);
ctx.fillStyle = shade(c, 1.04); ctx.fillRect(i * cw + 9, j * rh + 13, cw - 18, rh - 26);
}
}
noise(ctx, W, H, 4, r);
},
door(ctx, c, r, W, H) { // 6-panel door
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
const P = [[0.12, 0.06, 0.34, 0.22], [0.54, 0.06, 0.34, 0.22], [0.12, 0.33, 0.34, 0.28], [0.54, 0.33, 0.34, 0.28], [0.12, 0.66, 0.34, 0.28], [0.54, 0.66, 0.34, 0.28]];
for (const [x, y, w, h] of P) { ctx.fillStyle = shade(c, 0.78); ctx.fillRect(x * W, y * H, w * W, h * H); ctx.fillStyle = shade(c, 0.95); ctx.fillRect(x * W + 8, y * H + 8, w * W - 16, h * H - 16); }
noise(ctx, W, H, 6, r);
},
};
// name -> [recipe, inchesU, inchesV, extra args]
const SPEC = {
vinyl: ['vinyl', 96, 96], fibercement: ['fibercement', 84, 84], board: ['board', 72, 96], shake: ['shake', 84, 84],
brick: ['brick', 80, 84], stone: ['stone', 96, 96], stucco: ['stucco', 48, 48],
asphalt: ['asphalt', 60, 60], metal: ['metal', 96, 96],
oak: ['wood', 96, 96, 5], walnut: ['wood', 96, 96, 5], lvp: ['wood', 96, 96, 7],
tile: ['tile', 96, 96, 24, 12], tile12: ['tile', 96, 96, 12, 12], carpet: ['carpet', 48, 48], concrete: ['concrete', 96, 96],
paint: ['paint', 96, 96], grass: ['grass', 120, 120], dirt: ['dirt', 96, 96], foundation: ['concrete', 96, 96],
garagedoor: ['garagedoor', 192, 84], door: ['door', 36, 80], asphaltdrive: ['dirt', 96, 96],
brick_stack: ['brick', 80, 84, 'stack', 80], brick_flemish: ['brick', 72, 84, 'flemish', 72], herring: ['herring', 48, 48],
soffit: ['soffit', 48, 48], wp_stripe: ['wpStripe', 24, 24], wp_damask: ['wpDamask', 24, 24], wp_floral: ['wpFloral', 24, 24],
wp_check: ['wpCheck', 24, 24], wp_grass: ['wpGrass', 36, 36], bs_subway: ['subway', 48, 48], bs_hex: ['hex', 45, 16 * 1.5 * Math.sqrt(3)],
bs_mosaic: ['mosaic', 24, 24], bs_herring: ['herring', 24, 24, '#efece6'], wn_bead: ['bead', 24, 48], galv: ['galv', 48, 48], gravel: ['gravel', 48, 48],
};
export const DEFAULT_COLORS = { oak: '#b8874f', walnut: '#6e4a30', lvp: '#a39d92', tile: '#d9d6d0', tile12: '#cfc4b0',
carpet: '#9d978e', concrete: '#a9a7a2', grass: '#5d7d3a', dirt: '#8a6a4a', foundation: '#9c9a95', asphaltdrive: '#5b5b5b',
brick: '#9a4a32', stone: '#a49a88', paint: '#eeeae2', brick_stack: '#9a4a32', brick_flemish: '#9a4a32', herring: '#9a4a32',
soffit: '#f4f2ee', galv: '#b9bdc0', gravel: '#9a958b', bs_subway: '#f2f0ea', bs_hex: '#f2f0ea', bs_mosaic: '#6f9fb0', bs_herring: '#f2f0ea', wn_bead: '#f4f2ee' };
export function texture(name, color) {
const spec = SPEC[name] || SPEC.paint;
color = color || DEFAULT_COLORS[name] || '#cccccc';
const key = name + color;
if (cache.has(key)) return cache.get(key);
const [recipe, inU, inV, ...extra] = spec;
const cv = document.createElement('canvas');
cv.width = PX; cv.height = Math.round(PX * inV / inU);
const ctx = cv.getContext('2d', { willReadFrequently: true });
let seed = 0; for (const ch of key) seed = seed * 31 + ch.charCodeAt(0);
RECIPES[recipe](ctx, hex(color), rng(seed), cv.width, cv.height, ...extra);
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1 / inU, 1 / inV); // geometry UVs are in inches
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
const out = { tex, inU, inV, canvas: cv };
cache.set(key, out);
return out;
}
// Door / garage door face drawn from doorLayout() at the door's real size. UVs run 0..1 across the
// face. Glass panes are cut out (alpha) so the glass pane behind them shows and light passes through.
export function doorMaterial(o, layout, color) {
const key = ['doorface', o.kind, layout.style, color, o.w, o.h].join('|');
if (cache.has(key)) return cache.get(key);
const ppi = Math.min(6, 1024 / Math.max(o.w, o.h));
const W = Math.round(o.w * ppi), H = Math.round(o.h * ppi);
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
const ctx = cv.getContext('2d', { willReadFrequently: true }), c = hex(color), r = rng(W * 7 + H);
const s = layout.style;
ctx.fillStyle = shade(c, 1); ctx.fillRect(0, 0, W, H);
if (s === 'barn' || s === 'carriage') { // vertical boards
for (let x = 0; x < W; x += 3 * ppi) { ctx.fillStyle = shade(c, 0.9 + r() * 0.15); ctx.fillRect(x, 0, 3 * ppi, H); }
}
if (s === 'glass') { ctx.fillStyle = shade(c, 1.05); ctx.fillRect(0, 0, W, H); }
const e = Math.max(2, ppi * 0.6);
for (const [x, y, w, h, t] of layout.rects) {
const X = x * W, Y = y * H, w2 = w * W, h2 = h * H;
if (t === 'line') { ctx.fillStyle = shade(c, 0.6); ctx.fillRect(X - 1, Y - 1, Math.max(2, w2), Math.max(2, h2)); continue; }
if (t === 'panel') {
ctx.fillStyle = shade(c, 0.74); ctx.fillRect(X, Y, w2, h2);
ctx.fillStyle = shade(c, 1.06); ctx.fillRect(X + e, Y + e, w2 - 2 * e, h2 - 2 * e);
ctx.fillStyle = shade(c, 0.97); ctx.fillRect(X + 2 * e, Y + 2 * e, w2 - 4 * e, h2 - 4 * e);
continue;
}
ctx.fillStyle = shade(c, 0.6); ctx.fillRect(X - 2, Y - 2, w2 + 4, h2 + 4); // glass stop
ctx.clearRect(X, Y, w2, h2);
}
// leaf meeting line for double doors, strap hinges for barn and carriage doors
for (const x of layout.leaves.slice(1, -1)) { ctx.fillStyle = shade(c, 0.45); ctx.fillRect(x * W - 1.5, 0, 3, H); }
if (s === 'barn' || s === 'carriage') {
ctx.fillStyle = '#26262a';
const ys = s === 'barn' ? [0.12, 0.88] : [0.35, 0.8];
for (const y of ys) { ctx.fillRect(0, y * H, W * (s === 'barn' ? 0.35 : 0.14), 2.2 * ppi); ctx.fillRect(W - W * (s === 'barn' ? 0.35 : 0.14), y * H, W * (s === 'barn' ? 0.35 : 0.14), 2.2 * ppi); }
}
noise(ctx, W, H, 5, r);
// noise() also adds to cleared pixels' colour only, alpha stays 0 so the cut-outs survive
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace; tex.anisotropy = 8;
const m = new THREE.MeshStandardMaterial({ map: tex, alphaTest: 0.5, roughness: s === 'glass' ? 0.35 : 0.6, metalness: s === 'glass' ? 0.5 : 0, side: THREE.DoubleSide });
cache.set(key, m);
return m;
}
export function material(name, color, opts = {}) {
const t = texture(name, color);
const rough = { metal: 0.35, tile: 0.25, tile12: 0.3, concrete: 0.55, oak: 0.45, walnut: 0.45, lvp: 0.5, galv: 0.45,
bs_subway: 0.18, bs_hex: 0.2, bs_mosaic: 0.12, bs_herring: 0.2, soffit: 0.6 }[name] ?? 0.9;
const m = new THREE.MeshStandardMaterial({ map: t.tex, bumpMap: t.tex, bumpScale: opts.bump ?? 1.2, roughness: rough,
metalness: name === 'metal' ? 0.6 : name === 'galv' ? 0.55 : 0, side: opts.side ?? THREE.FrontSide });
m.userData.inU = t.inU; m.userData.inV = t.inV;
return m;
}
EOF
view3d.js: the 3D scene: ground, driveway, foundations, walls with openings, roofs, sky, sun and walk mode.
cat > /root/build/view3d.js <<'EOF'
// 3D view: builds the lot, ground, foundations, walls, openings and roofs from the project.
// Three.js units are inches. Plan (x, y, z-up) maps to three (x, z, -y).
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { Sky } from 'three/addons/objects/Sky.js';
import { RectAreaLightUniformsLib } from 'three/addons/lights/RectAreaLightUniformsLib.js';
import { FT, extWalls, levelsOf, levelBase, plateZ, FLOOR_DEPTH, designAt, groundAt, roofGeometry, byId,
sharedWalls, wallShare, wallOpenings, splitShared } from './model.js';
import { material, doorMaterial, DEFAULT_COLORS } from './textures.js';
import { doorLayout, doorColor, extDetails, isGlass, friezeTop } from './model.js';
import { FURN } from './furniture.js';
import { furnitureModel } from './furniture3d.js';
import { floorHoles, walkFloor } from './stairs.js';
import { rectsMinus } from './rooms.js';
import { stairs3d, roofTrim, roomInside, siteItems3d, windowWell3d, wellHoles } from './details3d.js';
const MAX_FIXTURE_SHADOWS = 6; // each one costs a shadow map in every material's shader
const NIGHT_EXPOSURE = 0.9;
const V = (x, y, z) => new THREE.Vector3(x, z, -y);
const UP = new THREE.Vector3(0, 1, 0);
function worldUV(geo) {
const pos = geo.attributes.position, nor = geo.attributes.normal;
const uv = new Float32Array(pos.count * 2), p = new THREE.Vector3(), n = new THREE.Vector3(), t = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
p.fromBufferAttribute(pos, i); n.fromBufferAttribute(nor, i);
if (Math.abs(n.y) > 0.7) { uv[i * 2] = p.x; uv[i * 2 + 1] = -p.z; }
else { t.crossVectors(UP, n).normalize(); uv[i * 2] = p.dot(t); uv[i * 2 + 1] = p.y; }
}
geo.setAttribute('uv', new THREE.BufferAttribute(uv, 2));
return geo;
}
const matCache = new Map();
function M(name, color, opts = {}) {
const key = name + (color || '') + JSON.stringify(opts);
if (!matCache.has(key)) matCache.set(key, material(name, color, opts));
return matCache.get(key);
}
function plain(color, opts = {}) {
const key = 'plain' + color + JSON.stringify(opts);
if (!matCache.has(key)) matCache.set(key, new THREE.MeshStandardMaterial({ color, roughness: 0.6, ...opts }));
return matCache.get(key);
}
const GLASS = new THREE.MeshStandardMaterial({ color: 0x9fb7c8, roughness: 0.04, metalness: 0.9, transparent: true, opacity: 0.45, envMapIntensity: 1.5 });
// Box along plan line a->b from t0 to t1, across the right-hand normal from -inn to +out, z0..z1.
function wallBox(a, b, t0, t1, out, inn, z0, z1, mats, worldUv = true) {
const dx = b[0] - a[0], dy = b[1] - a[1], len = Math.hypot(dx, dy);
const d = [dx / len, dy / len], n = [d[1], -d[0]];
const L = t1 - t0, T = out + inn, H = z1 - z0;
if (L <= 0.01 || T <= 0.01 || H <= 0.01) return null;
const geo = new THREE.BoxGeometry(L, H, T);
const mid = (t0 + t1) / 2, k = (out - inn) / 2;
const cx = a[0] + d[0] * mid + n[0] * k, cy = a[1] + d[1] * mid + n[1] * k;
geo.applyMatrix4(new THREE.Matrix4().makeRotationY(Math.atan2(dy, dx)));
geo.applyMatrix4(new THREE.Matrix4().makeTranslation(cx, z0 + H / 2, -cy));
if (worldUv) worldUV(geo);
const mesh = new THREE.Mesh(geo, mats);
mesh.castShadow = mesh.receiveShadow = true;
return mesh;
}
function polyMesh(pts, mat) {
const P = pts.map(p => V(p[0], p[1], p[2]));
const n = new THREE.Vector3().crossVectors(P[1].clone().sub(P[0]), P[2].clone().sub(P[0])).normalize();
const t = P[1].clone().sub(P[0]).normalize(), bt = new THREE.Vector3().crossVectors(n, t);
const pos = [], uv = [];
for (let i = 1; i < P.length - 1; i++) for (const q of [P[0], P[i], P[i + 1]]) {
pos.push(q.x, q.y, q.z);
uv.push(q.dot(t), q.dot(bt));
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.computeVertexNormals();
const m = new THREE.Mesh(geo, mat);
m.castShadow = m.receiveShadow = true;
return m;
}
const H = { wallBox, polyMesh, M, plain, V };
// Splits [za, zb] of a wall into the bands that fall in it ([z0, z1, material]) and the plain wall between.
function splitZ(za, zb, bands, plainMat) {
const out = [];
let z = za;
for (const [b0, b1, m] of [...bands].sort((a, b) => a[0] - b[0])) {
const a = Math.max(za, b0), b = Math.min(zb, b1);
if (b <= a) continue;
if (a > z) out.push([z, a, plainMat]);
out.push([a, b, m]);
z = b;
}
if (z < zb) out.push([z, zb, plainMat]);
return out;
}
export class View3D {
constructor(el, app) {
this.el = el; this.app = app;
this.renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true, preserveDrawingBuffer: true });
this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.55;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// the scene only changes on rebuild / sun / day-night, so shadow maps are redrawn only then
this.renderer.shadowMap.autoUpdate = false;
RectAreaLightUniformsLib.init();
this.night = false; this.lightsOn = false; this.fixtures = [];
el.appendChild(this.renderer.domElement);
this.renderer.domElement.setAttribute('aria-label', '3D view. Drag to orbit, scroll to zoom. In walk mode use arrow keys or W A S D.');
this.renderer.domElement.tabIndex = 0;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(50, 1, 6, 4e6);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.maxPolarAngle = Math.PI * 0.495;
this.sky = new Sky(); this.sky.scale.setScalar(1.5e6);
this.scene.add(this.sky);
const u = this.sky.material.uniforms;
u.turbidity.value = 2.5; u.rayleigh.value = 1.2; u.mieCoefficient.value = 0.003; u.mieDirectionalG.value = 0.85;
this.scene.fog = new THREE.Fog(0xcfd8e0, 3e4, 1.8e5);
this.sun = new THREE.DirectionalLight(0xfff2df, 3.2);
this.sun.castShadow = true;
this.sun.shadow.mapSize.set(4096, 4096);
this.sun.shadow.bias = -0.0004; this.sun.shadow.normalBias = 2;
this.scene.add(this.sun, this.sun.target);
this.hemi = new THREE.HemisphereLight(0xcfe3ff, 0x5d5a3a, 0.7);
this.scene.add(this.hemi);
this.pmrem = new THREE.PMREMGenerator(this.renderer);
this.group = new THREE.Group(); this.scene.add(this.group);
this.hour = 15; this.opts = { roof: true, terrain: true, maxLevel: 99, furniture: true };
this.walk = null; this.keys = new Set();
new ResizeObserver(() => this.resize()).observe(el);
addEventListener('keydown', e => { if (this.walk && document.activeElement === this.renderer.domElement) { this.keys.add(e.key.toLowerCase()); if (e.key.startsWith('Arrow')) e.preventDefault(); } });
addEventListener('keyup', e => this.keys.delete(e.key.toLowerCase()));
const cv = this.renderer.domElement;
cv.addEventListener('pointerdown', e => { if (this.walk) { this.walk.drag = [e.clientX, e.clientY]; cv.setPointerCapture(e.pointerId); } });
cv.addEventListener('pointermove', e => {
if (!this.walk?.drag) return;
this.walk.yaw -= (e.clientX - this.walk.drag[0]) * 0.004; this.walk.pitch -= (e.clientY - this.walk.drag[1]) * 0.004;
this.walk.pitch = Math.max(-1.3, Math.min(1.3, this.walk.pitch)); this.walk.drag = [e.clientX, e.clientY];
});
cv.addEventListener('pointerup', () => { if (this.walk) this.walk.drag = null; });
this.clock = new THREE.Clock();
this.renderer.setAnimationLoop(() => this.tick());
this.setHour(this.hour);
}
resize() {
const r = this.el.getBoundingClientRect();
if (!r.width) return;
this.renderer.setSize(r.width, r.height);
this.camera.aspect = r.width / r.height; this.camera.updateProjectionMatrix();
}
setHour(h) {
this.hour = h;
if (this.night) return;
const el = Math.max(2, Math.sin(Math.PI * (h - 6) / 14) * 62) * Math.PI / 180;
const az = (90 + (h - 6) / 14 * 180) * Math.PI / 180;
this.sunDir = new THREE.Vector3(Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el));
this.sky.material.uniforms.sunPosition.value.copy(this.sunDir);
this.sun.intensity = 0.6 + 2.8 * Math.sin(el);
this.sun.color.setHSL(0.09, 0.6, 0.6 + 0.35 * Math.sin(el));
this.placeSun();
clearTimeout(this._envT);
this._envT = setTimeout(() => {
const s = new THREE.Scene(); const sky = this.sky.clone(); sky.visible = true; s.add(sky);
this.env?.dispose();
this.env = this.pmrem.fromScene(s, 0, 100, 2e6).texture;
this.scene.environment = this.night ? null : this.env;
}, 150);
}
// Night: no sky or sky light, faint blue moonlight, exposure up so the fixtures carry the scene.
setNight(on) {
this.night = on;
this.sky.visible = !on;
this.scene.background = on ? new THREE.Color('#060912') : null;
this.scene.fog.color.set(on ? '#060912' : '#cfd8e0');
this.scene.environment = on ? null : this.env || null;
this.renderer.toneMappingExposure = on ? NIGHT_EXPOSURE : 0.55;
this.hemi.intensity = on ? 0.08 : 0.7;
if (on) {
this.sunDir = new THREE.Vector3(-0.45, 0.7, 0.55).normalize();
this.sun.intensity = 0.2; this.sun.color.set('#9db4ff');
this.placeSun();
} else this.setHour(this.hour);
}
setLights(on) { this.lightsOn = on; this.applyLights(); }
applyLights() {
for (const fx of this.fixtures) {
for (const [L, I] of fx.lights) L.intensity = this.lightsOn ? I : 0;
for (const m of fx.glow) m.emissiveIntensity = this.lightsOn ? fx.gi : 0;
}
}
placeSun() {
const p = this.app.project, c = this.center || new THREE.Vector3();
const R = Math.max(p.site.w, p.site.d) * 0.75 + 600;
this.sun.target.position.copy(c);
this.sun.position.copy(c).addScaledVector(this.sunDir, R * 2);
const sc = this.sun.shadow.camera;
sc.left = sc.bottom = -R; sc.right = sc.top = R; sc.near = 10; sc.far = R * 5;
sc.updateProjectionMatrix();
this.renderer.shadowMap.needsUpdate = true;
}
tick() {
const dt = Math.min(this.clock.getDelta(), 0.1);
if (this.walk) {
const w = this.walk, k = this.keys, sp = (k.has('shift') ? 160 : 60) * dt;
if (k.has('q')) w.yaw += 1.6 * dt; if (k.has('e')) w.yaw -= 1.6 * dt;
const f = new THREE.Vector3(-Math.sin(w.yaw), 0, -Math.cos(w.yaw)), r = new THREE.Vector3(-f.z, 0, f.x);
if (k.has('w') || k.has('arrowup')) w.pos.addScaledVector(f, sp);
if (k.has('s') || k.has('arrowdown')) w.pos.addScaledVector(f, -sp);
if (k.has('a')) w.pos.addScaledVector(r, -sp);
if (k.has('d')) w.pos.addScaledVector(r, sp);
if (k.has('arrowleft')) w.yaw += 1.6 * dt; if (k.has('arrowright')) w.yaw -= 1.6 * dt;
// feet follow the floor, the stairs and the ground
const p = this.app.project, px = w.pos.x, py = -w.pos.z;
const target = walkFloor(p, px, py, w.feet, designAt(p, px, py));
w.feet += (target - w.feet) * Math.min(1, dt * 12);
w.pos.y = w.feet + 66;
this.camera.position.copy(w.pos);
this.camera.rotation.set(w.pitch, w.yaw, 0, 'YXZ');
} else this.controls.update();
this.renderer.render(this.scene, this.camera);
}
startWalk(levelZ) {
const c = this.center;
this.walk = { pos: new THREE.Vector3(c.x, levelZ + 66, c.z + 30), yaw: 0, pitch: 0, feet: levelZ };
this.controls.enabled = false;
this.camera.near = 2; this.camera.updateProjectionMatrix();
this.renderer.domElement.focus();
}
stopWalk() { this.walk = null; this.controls.enabled = true; this.camera.near = 6; this.camera.updateProjectionMatrix(); this.home(); }
home() {
const p = this.app.project, ss = p.structures;
const x0 = ss.length ? Math.min(...ss.map(s => s.x)) : 0, x1 = ss.length ? Math.max(...ss.map(s => s.x + s.w)) : p.site.w;
const y0 = ss.length ? Math.min(...ss.map(s => s.y)) : 0, y1 = ss.length ? Math.max(...ss.map(s => s.y + s.d)) : p.site.d;
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2, z = designAt(p, cx, cy);
const span = Math.max(x1 - x0, y1 - y0, 30 * FT);
this.controls.target.copy(V(cx, cy, z + 60));
this.camera.position.copy(V(cx - span * 0.55, cy - span * 1.25, z + span * 0.55));
this.controls.update();
}
// "Photo render": the same view drawn at up to twice the size and scaled down (smooth edges), with a
// sharper sun shadow fitted to the buildings and shadows from more of the lights. Returns a JPEG data URL.
photo(width = 2560) {
const r = this.renderer, gl = r.getContext(), cv = r.domElement;
const size = r.getSize(new THREE.Vector2()), pr = r.getPixelRatio(), aspect = size.x / size.y;
const limit = Math.min(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE), gl.getParameter(gl.MAX_VIEWPORT_DIMS)[0], 8192);
const ss = Math.max(1, Math.min(2, limit / width)), W = Math.round(width * ss), Hh = Math.round(width / aspect * ss);
const sh = this.sun.shadow, oldMap = sh.mapSize.clone(), sc = sh.camera, oldCam = [sc.left, sc.right, sc.top, sc.bottom];
const big = gl.getParameter(gl.MAX_TEXTURE_SIZE) >= 8192 ? 6144 : 4096;
sh.mapSize.set(big, big); sh.map?.dispose(); sh.map = null;
const ss2 = this.app.project.structures;
if (ss2.length && !this.night) {
const x0 = Math.min(...ss2.map(s => s.x)) - 30 * FT, x1 = Math.max(...ss2.map(s => s.x + s.w)) + 30 * FT;
const y0 = Math.min(...ss2.map(s => s.y)) - 30 * FT, y1 = Math.max(...ss2.map(s => s.y + s.d)) + 30 * FT;
const R = Math.hypot(x1 - x0, y1 - y0) / 2, c = V((x0 + x1) / 2, (y0 + y1) / 2, this.center.y);
this.sun.target.position.copy(c); this.sun.position.copy(c).addScaledVector(this.sunDir, R * 2);
sc.left = sc.bottom = -R; sc.right = sc.top = R; sc.updateProjectionMatrix();
}
const extra = [];
if (this.lightsOn) for (const fx of this.fixtures) for (const [L] of fx.lights) if (!L.isRectAreaLight && !L.castShadow && extra.length < 10) { L.castShadow = true; extra.push(L); }
r.setPixelRatio(1); r.setSize(W, Hh, false);
r.shadowMap.needsUpdate = true;
r.render(this.scene, this.camera);
const out = document.createElement('canvas'); out.width = width; out.height = Math.round(width / aspect);
const ctx = out.getContext('2d');
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(cv, 0, 0, out.width, out.height);
// a soft vignette, like a camera lens
const g = ctx.createRadialGradient(out.width / 2, out.height / 2, out.height * 0.45, out.width / 2, out.height / 2, Math.hypot(out.width, out.height) / 2);
g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.28)');
ctx.fillStyle = g; ctx.fillRect(0, 0, out.width, out.height);
// put everything back
for (const L of extra) L.castShadow = false;
sh.mapSize.copy(oldMap); sh.map?.dispose(); sh.map = null;
[sc.left, sc.right, sc.top, sc.bottom] = oldCam; sc.updateProjectionMatrix();
r.setPixelRatio(pr); r.setSize(size.x, size.y, false);
this.placeSun();
return out.toDataURL('image/jpeg', 0.92);
}
build() {
const p = this.app.project, o = this.opts;
for (const ch of [...this.group.children]) {
this.group.remove(ch);
ch.traverse?.(m => { m.geometry?.dispose(); if (m.isLight) m.dispose?.(); });
}
for (const fx of this.fixtures) for (const m of fx.glow) m.dispose();
this.fixtures = [];
const G = this.group;
const ss = p.structures;
const cx = ss.length ? ss.reduce((a, s) => a + s.x + s.w / 2, 0) / ss.length : p.site.w / 2;
const cy = ss.length ? ss.reduce((a, s) => a + s.y + s.d / 2, 0) / ss.length : p.site.d / 2;
this.center = V(cx, cy, designAt(p, cx, cy));
this.placeSun();
if (o.terrain) { G.add(this.terrain(p)); siteItems3d(p, G, H); }
this.shares = sharedWalls(p);
for (const s of ss) this.structure(p, s, G);
// shadows for the fixtures nearest the camera's focus (walk position in walk mode)
const focus = this.walk ? this.walk.pos : this.controls.target;
const lit = this.fixtures.filter(fx => fx.gi).sort((a, b) => a.obj.position.distanceTo(focus) - b.obj.position.distanceTo(focus));
let n = 0;
for (const fx of lit) for (const [L] of fx.lights) if (!L.isRectAreaLight && n < MAX_FIXTURE_SHADOWS) { L.castShadow = true; n++; }
this.applyLights();
if (!this._homed) { this._homed = true; this.home(); }
}
terrain(p) {
const m = 40 * FT, step = 24;
const W = p.site.w + 2 * m, D = p.site.d + 2 * m;
const nx = Math.ceil(W / step), ny = Math.ceil(D / step);
const pos = [], col = [], uv = [], idx = [];
const drives = [];
if (p.site.drive) for (const g of ss(p)) {
const doors = p.openings.filter(o => o.s === g.id && o.kind === 'garage' && o.wall === 'S' && o.lvl === 0);
if (doors.length) drives.push([g.x + Math.min(...doors.map(o => o.off)) - 18, g.x + Math.max(...doors.map(o => o.off + o.w)) + 18, g.y]);
}
const vert = (x, y) => {
const e = groundAt(p.site, x, y), z = designAt(p, x, y);
pos.push(x, z, -y); uv.push(x, y);
const dist = Math.min(1, Math.abs(z - e) / 8);
const outside = x < 0 || y < 0 || x > p.site.w || y > p.site.d;
col.push(1 - 0.25 * dist + (outside ? -0.08 : 0), 1 - 0.45 * dist + (outside ? -0.05 : 0), 1 - 0.6 * dist + (outside ? -0.08 : 0));
return pos.length / 3 - 1;
};
for (let j = 0; j <= ny; j++) for (let i = 0; i <= nx; i++) vert(-m + i * W / nx, -m + j * D / ny);
// window wells are holes in the ground: grid cells touching one are re-cut finely around it
const holes = wellHoles(p), patch = [];
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
const x0 = -m + i * W / nx, x1 = x0 + W / nx, y0 = -m + j * D / ny, y1 = y0 + D / ny;
if (holes.some(h => h.x1 > x0 && h.x0 < x1 && h.y1 > y0 && h.y0 < y1)) { patch.push([x0, y0, x1, y1]); continue; }
const a = j * (nx + 1) + i, b = a + 1, c = a + nx + 1, d = c + 1;
idx.push(a, b, d, a, d, c);
}
for (const [x0, y0, x1, y1] of patch) {
const cuts = (lo, hi, k0, k1) => [...new Set([lo, hi, ...holes.flatMap(h => [h[k0], h[k1]]).filter(v => v > lo && v < hi)])].sort((a, b) => a - b);
const xs = cuts(x0, x1, 'x0', 'x1'), ys = cuts(y0, y1, 'y0', 'y1');
for (let j = 0; j < ys.length - 1; j++) for (let i = 0; i < xs.length - 1; i++) {
const xm = (xs[i] + xs[i + 1]) / 2, ym = (ys[j] + ys[j + 1]) / 2;
if (holes.some(h => xm > h.x0 && xm < h.x1 && ym > h.y0 && ym < h.y1)) continue;
const a = vert(xs[i], ys[j]), b = vert(xs[i + 1], ys[j]), c = vert(xs[i], ys[j + 1]), d = vert(xs[i + 1], ys[j + 1]);
idx.push(a, b, d, a, d, c);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
geo.setIndex(idx); geo.computeVertexNormals();
const mat = material('grass');
mat.vertexColors = true;
const mesh = new THREE.Mesh(geo, mat);
mesh.receiveShadow = true;
// concrete driveways, following the ground 1" above it
for (const [a, b, gy] of drives) {
const n = Math.max(2, Math.ceil((gy + 3 * FT) / step));
const dg = new THREE.PlaneGeometry(b - a, gy + 3 * FT, 2, n);
const P = dg.attributes.position;
for (let i = 0; i < P.count; i++) {
const x = (a + b) / 2 + P.getX(i), y = (gy - 3 * FT) / 2 + P.getY(i);
P.setXYZ(i, x, designAt(p, x, Math.min(y, gy - 1)) + 1, -y);
}
dg.computeVertexNormals(); worldUV(dg);
const dm = new THREE.Mesh(dg, M('concrete', '#b4b1aa'));
dm.receiveShadow = true;
mesh.add(dm);
}
// flat ground out to the horizon
const lowZ = Math.min(...p.site.grid.z) - 2;
const far = new THREE.Mesh(new THREE.RingGeometry(Math.min(W, D) * 0.45, 2e5, 64, 1), plain('#55703a', { roughness: 1 }));
far.rotation.x = -Math.PI / 2; far.position.set(p.site.w / 2, lowZ, -p.site.d / 2);
far.receiveShadow = true;
mesh.add(far);
return mesh;
}
structure(p, s, G) {
const o = this.opts, levels = levelsOf(s);
const sid = s.ext.mat, sc = s.ext.color, ed = extDetails(s);
const bonded = (m) => m === 'brick' && ed.bond !== 'running' ? 'brick_' + ed.bond : m;
const siding = M(bonded(sid), ['brick', 'stone'].includes(sid) && sc === '#c9c3b6' ? DEFAULT_COLORS[sid] : sc);
const wain = s.ext.wainscot ? M(bonded(s.ext.wainscotMat), DEFAULT_COLORS[s.ext.wainscotMat]) : null;
const brickCol = sid === 'brick' && sc !== '#c9c3b6' ? sc : DEFAULT_COLORS.brick;
const accent = ed.accent !== 'none' ? M('herring', brickCol) : null;
const conc = M('foundation');
const trim = plain(s.ext.trim);
const inside = M('paint', s.wallColor);
const W = extWalls(s), T = 6;
let topVaults = [];
// foundation ring
const fBottom = s.foundation === 'slab' ? s.ffe - 18 : s.foundation === 'crawl' ? s.ffe - 54 : levelBase(s, -1) - 14;
const fTop = levels.includes(-1) ? levelBase(s, -1) : s.ffe;
for (const k of 'NESW') {
const w = W[k], short = k === 'E' || k === 'W';
const m = wallBox(w.a, w.b, short ? 8 : 0, short ? w.len - 8 : w.len, 0, 8, fBottom, fTop, conc);
if (m) G.add(m);
}
for (const lvl of levels) {
if (lvl > o.maxLevel) continue;
const base = levelBase(s, lvl), top = base + s.wallH + (lvl < s.floors - 1 ? FLOOR_DEPTH : 0);
// floor deck
const deckMat = lvl === -1 || s.kind === 'garage' ? M('concrete') : M(s.floor || 'oak');
// stair openings are cut out of the floor deck and the room floors
const holes = floorHoles(p, s, lvl).map(h => ({ x0: s.x + h.x0, y0: s.y + h.y0, x1: s.x + h.x1, y1: s.y + h.y1 }));
const slab = (r, z0, z1, mat, shadows = true) => {
const yc = (r.y0 + r.y1) / 2, hd = (r.y1 - r.y0) / 2, m = wallBox([r.x0, yc], [r.x1, yc], 0, r.x1 - r.x0, hd, hd, z0, z1, mat);
if (m) { m.castShadow = shadows; G.add(m); }
};
for (const q of rectsMinus({ x0: s.x + T, y0: s.y + T, x1: s.x + s.w - T, y1: s.y + s.d - T }, holes)) slab(q, base - (lvl <= 0 ? 4 : FLOOR_DEPTH), base, deckMat);
for (const r of p.rooms.filter(r => r.s === s.id && r.lvl === lvl)) {
const x0 = Math.max(s.x + r.x, s.x + T), x1 = Math.min(s.x + r.x + r.w, s.x + s.w - T);
const y0 = Math.max(s.y + r.y, s.y + T), y1 = Math.min(s.y + r.y + r.d, s.y + s.d - T);
if (x1 > x0 && y1 > y0) for (const q of rectsMinus({ x0, y0, x1, y1 }, holes)) slab(q, base, base + 0.4, M(r.floor), false);
}
stairs3d(p, s, lvl, G, H);
// ceilings only when the floor above (or the roof) is shown, so a cut-away view can look in
const vaults = roomInside(p, s, lvl, G, H, { ...o, ceil: lvl < s.floors - 1 ? o.maxLevel > lvl : o.roof && o.maxLevel >= lvl });
if (lvl === s.floors - 1) topVaults = vaults;
// exterior walls: wainscot, herringbone accent band and siding, bottom to top
const outerMat = lvl === -1 ? conc : siding;
const bands = [];
if (lvl === levels.find(l => l >= 0)) {
if (wain) bands.push([base, base + s.ext.wainscot, wain]);
if (accent && ed.accent === 'base') { const z0 = base + (wain ? s.ext.wainscot : 0); bands.push([z0, z0 + ed.accentH, accent]); }
}
if (accent && ed.accent === 'frieze' && lvl === s.floors - 1) { const zt = Math.min(base + s.wallH, friezeTop(s)); bands.push([zt - ed.accentH, zt, accent]); }
for (const k of 'NESW') {
const w = W[k], short = k === 'E' || k === 'W';
// a wall shared with an attached building is drawn once; its far face gets the neighbour's paint
const sh = wallShare(p, s, k, lvl, this.shares);
const ops = wallOpenings(p, s, k, lvl, sh);
const piece = (t0, t1, za, zb) => {
t0 = Math.max(t0, short ? T : 0); t1 = Math.min(t1, short ? w.len - T : w.len);
if (t1 <= t0) return;
for (const [ta, tb, tag] of splitShared(t0, t1, sh)) {
if (tag === 'skip') continue;
const outer = tag === 'out' ? outerMat : lvl === -1 ? conc : M('paint', tag.wallColor);
const parts = tag === 'out' && lvl >= 0 ? splitZ(za, zb, bands, outer) : [[za, zb, outer]];
for (const [a, b, mt] of parts) { const m = wallBox(w.a, w.b, ta, tb, 0, T, a, b, [mt, mt, trim, trim, mt, lvl === -1 ? conc : inside]); if (m) G.add(m); }
}
};
let t = 0;
for (const op of ops) {
piece(t, op.off, base, top);
piece(op.off, op.off + op.w, base, base + op.sill);
piece(op.off, op.off + op.w, base + op.sill + op.h, top);
this.opening(G, s, w, op, base, true);
t = Math.max(t, op.off + op.w);
}
piece(t, w.len, base, top);
}
// interior walls
for (const iw of p.walls.filter(w => w.s === s.id && w.lvl === lvl)) {
const a = [s.x + iw.a[0], s.y + iw.a[1]], b = [s.x + iw.b[0], s.y + iw.b[1]];
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
const mat = lvl === -1 && !iw.color ? M('paint', '#dedbd4') : M('paint', iw.color || s.wallColor);
const ops = p.openings.filter(op => op.wall === iw.id).sort((x, y) => x.off - y.off);
const h = iw.t / 2;
// stop at the inside face of the outside walls, so the end doesn't show through the siding
let t = 0, tEnd = len;
for (const k of [0, 1]) {
const d = (iw.b[k] - iw.a[k]) / len, lim = k ? s.d : s.w;
if (Math.abs(d) < 0.999) continue;
const tLo = (T - iw.a[k]) / d, tHi = (lim - T - iw.a[k]) / d;
t = Math.max(t, Math.min(tLo, tHi)); tEnd = Math.min(tEnd, Math.max(tLo, tHi));
}
const add = (m) => m && G.add(m);
for (const op of ops) {
add(wallBox(a, b, t, op.off, h, h, base, base + s.wallH, mat));
add(wallBox(a, b, op.off, op.off + op.w, h, h, base, base + op.sill, mat));
add(wallBox(a, b, op.off, op.off + op.w, h, h, base + op.sill + op.h, base + s.wallH, mat));
this.opening(G, s, { a, b, len }, op, base, false, iw.t);
t = Math.max(t, op.off + op.w);
}
add(wallBox(a, b, t, tEnd, h, h, base, base + s.wallH, mat));
}
// furniture and light fixtures
if (o.furniture) for (const f of (p.furniture || []).filter(f => f.s === s.id && f.lvl === lvl)) {
const def = FURN[f.type];
if (!def) continue;
const m = furnitureModel(f);
const z = def.mount === 'ceiling' ? base + s.wallH - f.h : base + 0.4 + (f.z || 0);
m.position.copy(V(s.x + f.x, s.y + f.y, z));
m.rotation.y = (f.rot || 0) * Math.PI / 180;
G.add(m);
if (m.userData.fix) { m.userData.fix.obj = m; this.fixtures.push(m.userData.fix); }
}
}
if (o.roof && o.maxLevel >= s.floors - 1) {
const rg = roofGeometry(s);
const rm = M(s.roof.mat, s.roof.color, { side: THREE.DoubleSide });
for (const poly of rg.roof) G.add(polyMesh(poly, rm));
const gm = M(sid, ['brick', 'stone'].includes(sid) && sc === '#c9c3b6' ? DEFAULT_COLORS[sid] : sc, { side: THREE.DoubleSide });
for (const g of rg.gables) G.add(polyMesh(g, gm));
// ceiling under the roof so the inside reads as a room (open over vaulted rooms)
const pz = plateZ(s);
for (const q of rectsMinus({ x0: s.x + T, y0: s.y + T, x1: s.x + s.w - T, y1: s.y + s.d - T }, topVaults)) {
const yc = (q.y0 + q.y1) / 2, hd = (q.y1 - q.y0) / 2, ceil = wallBox([q.x0, yc], [q.x1, yc], 0, q.x1 - q.x0, hd, hd, pz, pz + 1, plain('#f4f2ee'));
if (ceil) G.add(ceil);
}
roofTrim(p, s, G, H);
}
}
opening(G, s, w, op, base, ext, thick = 6) {
const T = ext ? 6 : thick, out = ext ? 0 : thick / 2, inn = ext ? 6 : thick / 2;
const z0 = base + op.sill, z1 = z0 + op.h, t0 = op.off, t1 = op.off + op.w;
const trim = plain(ext ? s.ext.trim : '#f4f2ee');
const tw = 3.5;
const add = (m) => m && G.add(m);
// casing / frame on the outside face (proud by 1")
const fo = out + 1;
add(wallBox(w.a, w.b, t0 - tw, t0, fo, inn, z0 - (op.sill ? tw : 0), z1 + tw, trim));
add(wallBox(w.a, w.b, t1, t1 + tw, fo, inn, z0 - (op.sill ? tw : 0), z1 + tw, trim));
add(wallBox(w.a, w.b, t0 - tw, t1 + tw, fo, inn, z1, z1 + tw, trim));
if (op.sill) add(wallBox(w.a, w.b, t0 - tw - 1, t1 + tw + 1, fo + 1, inn, z0 - tw, z0, trim));
const c = (out - inn) / 2; // wall centre line, across the normal
if (isGlass(op)) {
add(wallBox(w.a, w.b, t0, t1, c + 0.25, 0.25 - c, z0, z1, GLASS));
if (op.kind === 'egress' && ext) windowWell3d(this.app.project, s, op, G, H);
const bars = [];
if (op.kind === 'egress') { if (op.style === 'slider') bars.push(['v', 0.5]); }
else if (op.kind === 'slider') bars.push(['v', 0.5]);
else if (op.style === 'grid') { bars.push(['h', 0.5]); const n = Math.max(1, Math.round(op.w / 18)); for (let i = 1; i < n; i++) bars.push(['v', i / n]); }
else if (op.style !== 'plain') bars.push(['h', 0.5]);
for (const [dir, f] of bars) {
if (dir === 'h') add(wallBox(w.a, w.b, t0, t1, c + 0.75, 0.75 - c, z0 + op.h * f - 1, z0 + op.h * f + 1, trim));
else add(wallBox(w.a, w.b, t0 + op.w * f - 1, t0 + op.w * f + 1, c + 0.75, 0.75 - c, z0, z1, trim));
}
} else if (op.kind === 'garage' || op.kind === 'door') {
// door leaf: its face texture is drawn at real size (doorLayout); glass panes are cut out of it
const L = doorLayout(op), col = doorColor(op, s), face = doorMaterial(op, L, col), edge = plain(col);
const mats = [edge, edge, edge, edge, face, face];
const barn = L.style === 'barn' && !ext;
// across the wall: [k0, k1] measured along the outward normal
let k0, k1;
if (op.kind === 'garage') { k0 = -4; k1 = -2; }
else if (ext) { k0 = -3.25; k1 = -1.5; }
else if (barn) { k0 = -thick / 2 - 2.5; k1 = -thick / 2 - 1; }
else { k0 = c - 0.875; k1 = c + 0.875; }
const ta = barn ? t0 - 2 : t0, tb = barn ? t1 + 2 : t1, zt = barn ? z1 + 1.5 : z1;
const leaf = wallBox(w.a, w.b, ta, tb, k1, -k0, z0, zt, mats, false);
if (leaf) {
// box UVs run 0..1 on each face; barn doors overlap the opening, so stretch nothing else
add(leaf);
}
for (const [x, y, gw, gh, t] of L.rects) if (t === 'glass') {
const gt0 = ta + x * (tb - ta), gt1 = gt0 + gw * (tb - ta), gz1 = zt - y * (zt - z0), gz0 = gz1 - gh * (zt - z0);
const km = (k0 + k1) / 2;
const g = wallBox(w.a, w.b, gt0, gt1, km + 0.12, 0.12 - km, gz0, gz1, GLASS, false);
if (g) { g.castShadow = false; add(g); }
}
if (barn) add(wallBox(w.a, w.b, t0 - 4, t1 + op.w + 4, k1 + 0.5, -k0 - 0.5, zt + 0.5, zt + 2.5, plain('#222326', { metalness: 0.6, roughness: 0.4 })));
if (op.kind === 'door') {
// knobs on both faces near the latch edge of each leaf
const knob = plain('#c9a24a', { metalness: 0.8, roughness: 0.3 });
const latch = L.leaves.length > 2 ? [0.5 - 3 / op.w, 0.5 + 3 / op.w] : [1 - 3 / op.w];
if (barn) latch.splice(0, 1, 0.08);
for (const f of latch) {
const tk = ta + f * (tb - ta);
add(wallBox(w.a, w.b, tk - 1, tk + 1, k1 + 2, -k1, z0 + 35, z0 + 37, knob));
add(wallBox(w.a, w.b, tk - 1, tk + 1, k0, -k0 + 2, z0 + 35, z0 + 37, knob));
}
}
}
}
}
function ss(p) { return p.structures; }
EOF
elev.js: the elevation drawings.
cat > /root/build/elev.js <<'EOF'
// Elevation drawings (front / back / left / right), drawn from the same geometry as the 3D view.
import { FT, fmtLen, extWalls, levelsOf, levelBase, plateZ, designAt, roofGeometry, SIDING, doorLayout, doorColor,
extDetails, roofEdges, windowWell, friezeTop } from './model.js';
export const SIDES = { front: 'Front (from the street)', back: 'Back', left: 'Left side', right: 'Right side' };
const FACING = { front: 'S', back: 'N', left: 'W', right: 'E' };
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
function projector(p, side) {
const W = p.site.w, D = p.site.d;
return {
front: (x, y) => [x, y], back: (x, y) => [W - x, D - y],
left: (x, y) => [D - y, x], right: (x, y) => [y, W - x],
}[side];
}
const SIDING_FILL = { brick: 'url(#pat-brick)', stone: 'url(#pat-stone)' };
const BOND_FILL = { running: 'url(#pat-brick)', stack: 'url(#pat-brick-stack)', flemish: 'url(#pat-brick-flemish)' };
const fillOf = (s, mat, color) => mat === 'brick' ? BOND_FILL[extDetails(s).bond] || BOND_FILL.running : SIDING_FILL[mat] || color;
export function drawElevation(p, side, opts = {}) {
const u = opts.u || 1, pr = projector(p, side), face = FACING[side];
const items = [];
let h0 = Infinity, h1 = -Infinity, zTop = -Infinity, zBot = Infinity;
const pt = (x, y, z) => { const [h, d] = pr(x, y); h0 = Math.min(h0, h); h1 = Math.max(h1, h); zTop = Math.max(zTop, z); return [h, z, d]; };
// height labels go in columns to the right of everything, one column per building
let labelX = -Infinity;
for (const s of p.structures) for (const q of [[s.x - s.roof.overhang, s.y], [s.x + s.w + s.roof.overhang, s.y + s.d], [s.x - s.roof.overhang, s.y + s.d], [s.x + s.w + s.roof.overhang, s.y]]) labelX = Math.max(labelX, pr(...q)[0]);
let col = 0;
const wells = [];
for (const s of p.structures) {
const w = extWalls(s)[face];
const [ha, da] = pr(...w.a), [hb] = pr(...w.b);
const lo = Math.min(ha, hb);
const lv = levelsOf(s);
const zFloor = lv.includes(-1) ? levelBase(s, -1) : s.ffe, zPlate = plateZ(s);
const fb = s.foundation === 'slab' ? s.ffe - 18 : s.foundation === 'crawl' ? s.ffe - 54 : levelBase(s, -1) - 14;
zBot = Math.min(zBot, fb);
// wall face
const color = s.ext.color;
const wallPoly = [pt(...w.a, s.ffe), pt(...w.b, s.ffe), pt(...w.b, zPlate), pt(...w.a, zPlate)];
items.push({ d: da, svg: polySvg(wallPoly, `fill="${fillOf(s, s.ext.mat, color)}" stroke="#222" stroke-width="${1.4 * u}"`) + lapLines(s, lo, w.len, s.ffe, zPlate, u) });
if (lv.includes(-1)) items.push({ d: da, svg: polySvg([pt(...w.a, zFloor), pt(...w.b, zFloor), pt(...w.b, s.ffe), pt(...w.a, s.ffe)], `fill="#c9c6bf" stroke="#222" stroke-width="${1.2 * u}"`) });
items.push({ d: da - 0.1, svg: polySvg([pt(...w.a, fb), pt(...w.b, fb), pt(...w.b, zFloor), pt(...w.a, zFloor)], `fill="#c9c6bf" stroke="#444" stroke-width="${u}"`) });
const band = (z0, z1, fill) => polySvg([pt(...w.a, z0), pt(...w.b, z0), pt(...w.b, z1), pt(...w.a, z1)], `fill="${fill}" stroke="#222" stroke-width="${u}"`);
if (s.ext.wainscot) items.push({ d: da - 0.2, svg: band(s.ffe, s.ffe + s.ext.wainscot, fillOf(s, s.ext.wainscotMat, '#a99f8d')) });
const ed = extDetails(s);
if (ed.accent === 'base') items.push({ d: da - 0.2, svg: band(s.ffe + (s.ext.wainscot || 0), s.ffe + (s.ext.wainscot || 0) + ed.accentH, 'url(#pat-herring)') });
if (ed.accent === 'frieze') { const zt = friezeTop(s); items.push({ d: da - 0.2, svg: band(zt - ed.accentH, zt, 'url(#pat-herring)') }); }
// openings on the facing wall; egress windows in a well are drawn again after the ground
let os = '';
for (const o of p.openings.filter(o => o.s === s.id && o.wall === face)) {
const zb = levelBase(s, o.lvl) + o.sill, x = lo + o.off;
os += openingSvg(o, x, zb, u, s);
const ww = windowWell(p, s, o);
if (ww) {
const x0 = lo + ww.t0, x1 = lo + ww.t1;
wells.push(`<rect x="${x0}" y="${-ww.top}" width="${x1 - x0}" height="${ww.top - ww.bottom}" fill="#d4d6d8" stroke="#555" stroke-width="${u}" stroke-dasharray="${4 * u} ${2 * u}"/>` + openingSvg(o, x, zb, u, s) +
`<text x="${(x0 + x1) / 2}" y="${-ww.top - 4 * u}" font-size="${8 * u}" text-anchor="middle" fill="#555">window well ${fmtLen(ww.depth)} deep</text>`);
}
}
items.push({ d: da - 0.3, svg: os });
// fascia and gutters along the eaves, fascia up the rakes
const re = roofEdges(s), fw = ed.fascia;
for (const e of [...re.eaves, ...re.rakes]) {
const P = [pt(...e.a), pt(...e.b), pt(e.b[0], e.b[1], e.b[2] - fw), pt(e.a[0], e.a[1], e.a[2] - fw)];
if (fw) items.push({ d: avg(P) - 0.5, svg: polySvg(P, `fill="${ed.fasciaColor}" stroke="#222" stroke-width="${0.8 * u}"`) });
if (e.n && ed.gutter !== 'none' && s.roof.type !== 'flat') {
const o2 = 5.25, G = [[e.a[0] + e.n[0] * o2, e.a[1] + e.n[1] * o2], [e.b[0] + e.n[0] * o2, e.b[1] + e.n[1] * o2]];
const Q = [pt(...G[0], e.z - 0.5), pt(...G[1], e.z - 0.5), pt(...G[1], e.z - 5.5), pt(...G[0], e.z - 5.5)];
items.push({ d: avg(Q) - 0.6, svg: polySvg(Q, `fill="${ed.gutterColor}" stroke="#222" stroke-width="${0.8 * u}"`) });
}
}
if (ed.gutter !== 'none' && s.roof.type !== 'flat') for (const sp of re.spouts) {
const [h, , dd] = pt(sp.x, sp.y, sp.top), g = designAt(p, sp.x, sp.y);
items.push({ d: dd - 0.7, svg: `<rect x="${h - 1.5}" y="${-sp.top}" width="3" height="${sp.top - g - 4}" fill="${ed.gutterColor}" stroke="#222" stroke-width="${0.6 * u}"/>` });
}
// roof + gables
const rg = roofGeometry(s);
for (const g of rg.gables) {
const P = g.map(q => pt(...q));
items.push({ d: avg(P), svg: polySvg(P, `fill="${fillOf(s, s.ext.mat, color)}" stroke="#222" stroke-width="${1.2 * u}"`) });
}
for (const r of rg.roof) {
const P = r.map(q => pt(...q));
const fill = s.roof.mat === 'metal' ? s.roof.color : 'url(#pat-shingle)';
items.push({ d: avg(P), svg: polySvg(P, `fill="${fill}" stroke="#111" stroke-width="${1.4 * u}"`) });
}
// heights, labelled at the right of each building
const hr = labelX + 2 * FT + 10 * FT * col++;
const lab = (z, t) => `<line x1="${hr - FT}" y1="${-z}" x2="${hr + 0.5 * FT}" y2="${-z}" stroke="#1a5ad9" stroke-width="${u}"/>` +
`<text x="${hr + FT}" y="${-z}" font-size="${9 * u}" dominant-baseline="middle" fill="#1a5ad9">${esc(t)}</text>`;
let labels = lab(s.ffe, `${s.name.slice(0, 12)} floor`) + lab(zPlate, `Wall ${fmtLen(zPlate - s.ffe)}`) + lab(rg.ridgeZ, `Roof ${fmtLen(rg.ridgeZ - s.ffe)}`);
if (lv.includes(-1)) labels += lab(levelBase(s, -1), `Bsmt -${fmtLen(s.ffe - levelBase(s, -1))}`);
items.push({ d: -1e9, svg: labels });
h1 = Math.max(h1, hr + 9 * FT);
}
items.sort((a, b) => b.d - a.d);
// ground profile in front of the buildings
const nearest = (h) => {
let best = null;
for (const s of p.structures) {
const w = extWalls(s)[face], [ha, da] = pr(...w.a), [hb] = pr(...w.b);
if (h >= Math.min(ha, hb) - 6 && h <= Math.max(ha, hb) + 6 && (best == null || da < best)) best = da;
}
return best;
};
const inv = (h, dd) => {
// invert the projection for the ground sample
const W = p.site.w, D = p.site.d;
return { front: [h, dd], back: [W - h, D - dd], left: [dd, D - h], right: [W - dd, h] }[side];
};
const span = side === 'front' || side === 'back' ? p.site.w : p.site.d;
const fallback = Math.min(...p.structures.map(s => pr(...extWalls(s)[face].a)[1]), span);
const prof = [];
for (let h = 0; h <= span; h += 12) {
const d = nearest(h) ?? fallback;
prof.push([h, designAt(p, ...inv(h, Math.max(0, d - 1)))]);
}
const zGround = Math.min(...prof.map(q => q[1]));
zBot = Math.min(zBot, zGround) - 3 * FT;
h0 -= 2 * FT;
let svg = defs(u) + items.map(i => i.svg).join('');
svg += `<polygon points="${prof.map(q => `${q[0]},${-q[1]}`).join(' ')} ${span},${-zBot} 0,${-zBot}" fill="${opts.print ? '#fff' : '#efece4'}" stroke="none"/>`;
svg += wells.join('');
svg += `<polyline points="${prof.map(q => `${q[0]},${-q[1]}`).join(' ')}" fill="none" stroke="#3d2a14" stroke-width="${2.5 * u}"/>`;
// below-grade foundation, dashed
for (const s of p.structures) {
const w = extWalls(s)[face], [ha] = pr(...w.a), [hb] = pr(...w.b);
const lo = Math.min(ha, hb), hi = Math.max(ha, hb);
const fb = s.foundation === 'slab' ? s.ffe - 18 : s.foundation === 'crawl' ? s.ffe - 54 : levelBase(s, -1) - 14;
svg += `<rect x="${lo}" y="${-s.ffe}" width="${hi - lo}" height="${s.ffe - fb}" fill="none" stroke="#555" stroke-width="${u}" stroke-dasharray="${5 * u} ${3 * u}"/>`;
}
svg += `<text x="${(h0 + h1) / 2}" y="${-zBot - 1.2 * FT}" font-size="${11 * u}" text-anchor="middle" fill="#555">Finished grade shown as the heavy line. Dashed = foundation below ground.</text>`;
return { svg, bounds: { x0: h0, x1: h1, y0: zBot - 2 * FT, y1: zTop + 2 * FT } };
}
const avg = (P) => P.reduce((a, q) => a + q[2], 0) / P.length;
const polySvg = (P, attrs) => `<polygon points="${P.map(q => `${q[0].toFixed(1)},${(-q[1]).toFixed(1)}`).join(' ')}" ${attrs}/>`;
function lapLines(s, x0, len, z0, z1, u) {
const exp = { vinyl: 4, fibercement: 7, shake: 7 }[s.ext.mat];
let out = '';
if (exp) for (let z = z0 + exp; z < z1; z += exp) out += `<line x1="${x0}" y1="${-z}" x2="${x0 + len}" y2="${-z}" stroke="#000" stroke-opacity=".22" stroke-width="${0.5 * u}"/>`;
if (s.ext.mat === 'board') for (let x = x0 + 12; x < x0 + len; x += 12) out += `<line x1="${x}" y1="${-z0}" x2="${x}" y2="${-z1}" stroke="#000" stroke-opacity=".3" stroke-width="${0.7 * u}"/>`;
return out;
}
function openingSvg(o, x, zb, u, s) {
const y = -(zb + o.h), door = o.kind === 'door' || o.kind === 'garage';
let r = `<rect x="${x}" y="${y}" width="${o.w}" height="${o.h}" fill="${door ? doorColor(o, s) : '#a9c0d1'}" stroke="#222" stroke-width="${1.2 * u}"/>`;
r += `<rect x="${x - 3.5}" y="${y - 3.5}" width="${o.w + 7}" height="${o.h + 3.5 + (o.sill ? 3.5 : 0)}" fill="none" stroke="#222" stroke-width="${0.8 * u}"/>`;
if (door) {
// same panels, glass and grooves as the 3D door (doorLayout)
const L = doorLayout(o), X = (f) => x + f * o.w, Y = (f) => y + f * o.h;
for (const [fx, fy, fw, fh, t] of L.rects) {
if (t === 'line') r += `<line x1="${X(fx)}" y1="${Y(fy)}" x2="${X(fx + fw)}" y2="${Y(fy + fh)}" stroke="#000" stroke-opacity=".45" stroke-width="${0.6 * u}"/>`;
else r += `<rect x="${X(fx)}" y="${Y(fy)}" width="${fw * o.w}" height="${fh * o.h}" fill="${t === 'glass' ? '#a9c0d1' : 'none'}" stroke="#000" stroke-opacity=".5" stroke-width="${0.6 * u}"/>`;
}
for (const f of L.leaves.slice(1, -1)) r += `<line x1="${X(f)}" y1="${y}" x2="${X(f)}" y2="${y + o.h}" stroke="#222" stroke-width="${u}"/>`;
}
if (o.kind === 'egress' && o.style === 'slider') r += `<line x1="${x + o.w / 2}" y1="${y}" x2="${x + o.w / 2}" y2="${y + o.h}" stroke="#fff" stroke-width="${1.5 * u}"/>`;
if (o.kind === 'window' && o.style !== 'plain') r += `<line x1="${x}" y1="${y + o.h / 2}" x2="${x + o.w}" y2="${y + o.h / 2}" stroke="#fff" stroke-width="${1.5 * u}"/>`;
if (o.kind === 'window' && o.style === 'grid') { const n = Math.max(1, Math.round(o.w / 18)); for (let i = 1; i < n; i++) r += `<line x1="${x + o.w * i / n}" y1="${y}" x2="${x + o.w * i / n}" y2="${y + o.h}" stroke="#fff" stroke-width="${1.2 * u}"/>`; }
if (o.kind === 'slider') r += `<line x1="${x + o.w / 2}" y1="${y}" x2="${x + o.w / 2}" y2="${y + o.h}" stroke="#fff" stroke-width="${1.5 * u}"/>`;
if (o.kind === 'door') for (const cx of o.style === 'double' ? [x + o.w / 2 - 3, x + o.w / 2 + 3] : [x + o.w - 5]) r += `<circle cx="${cx}" cy="${y + o.h * 0.55}" r="1.4" fill="#d8b440"/>`;
return r;
}
function defs(u) {
return `<defs>
<pattern id="pat-brick" width="16" height="5.25" patternUnits="userSpaceOnUse"><rect width="16" height="5.25" fill="#a5553c"/><path d="M0 0H16M0 2.625H16M4 0V2.625M12 2.625V5.25" stroke="#e8e0d4" stroke-width=".4"/></pattern>
<pattern id="pat-brick-stack" width="8" height="2.625" patternUnits="userSpaceOnUse"><rect width="8" height="2.625" fill="#a5553c"/><path d="M0 0H8M0 0V2.625" stroke="#e8e0d4" stroke-width=".4"/></pattern>
<pattern id="pat-brick-flemish" width="12" height="5.25" patternUnits="userSpaceOnUse"><rect width="12" height="5.25" fill="#a5553c"/><rect x="8" width="4" height="2.625" fill="#8c4632"/><rect x="2" y="2.625" width="4" height="2.625" fill="#8c4632"/><path d="M0 0H12M0 2.625H12M0 0V2.625M8 0V2.625M2 2.625V5.25M6 2.625V5.25" stroke="#e8e0d4" stroke-width=".4"/></pattern>
<pattern id="pat-herring" width="12" height="4" patternUnits="userSpaceOnUse"><rect width="12" height="4" fill="#9a4a32"/><path d="M0 0L6 6M0 -4L6 2M0 4L6 10M6 6L12 0M6 2L12 -4M6 10L12 4M6 -2V12" stroke="#e8e0d4" stroke-width=".4"/></pattern>
<pattern id="pat-stone" width="30" height="14" patternUnits="userSpaceOnUse"><rect width="30" height="14" fill="#a99f8d"/><path d="M0 0H30M0 7H30M9 0V7M24 0V7M3 7V14M17 7V14" stroke="#5e584e" stroke-width=".6"/></pattern>
<pattern id="pat-shingle" width="12" height="5" patternUnits="userSpaceOnUse"><rect width="12" height="5" fill="#55585e"/><path d="M0 5H12M6 0V5" stroke="#2c2e31" stroke-width=".5"/></pattern>
</defs>`;
}
EOF
print.js: sheets, scales, page splitting and the title block.
cat > /root/build/print.js <<'EOF'
// Printing at true scale. Default 1/4" = 1' on US Letter, landscape.
// Each sheet is an SVG whose user units are real inches; the svg's physical size in
// paper inches fixes the scale. Drawings too big for one page are split into tiles.
import { FT, levelsOf, levelName } from './model.js';
import { drawPlan, planBounds } from './plan2d.js';
import { drawElevation, SIDES } from './elev.js';
export const SCALES = { 48: '1/4" = 1\' (default)', 96: '1/8" = 1\'', 24: '1/2" = 1\'', 12: '1" = 1\'' };
export const PAPERS = { letter: { name: 'Letter 8.5 x 11', w: 11, h: 8.5 }, tabloid: { name: 'Tabloid 11 x 17', w: 17, h: 11 } };
const MARGIN = 0.5, TITLE = 0.75;
// Site plans use an engineering scale (1" = 20', 30', ...) picked to fit one page.
const SITE_SCALES = [240, 360, 480, 600, 720, 1200];
const siteLabel = (sc) => `1" = ${sc / 12}'`;
export function sheetList(p) {
const L = [{ id: 'site', name: 'Site plan' }];
for (const s of p.structures) for (const l of levelsOf(s)) L.push({ id: `lvl:${l}:${s.id}`, name: `${s.name} - ${levelName(l)} plan` });
for (const k of Object.keys(SIDES)) L.push({ id: 'elev:' + k, name: `${SIDES[k]} elevation` });
return L;
}
let planOpts = {}; // { furn, lights }: include furniture / light fixtures on floor plan sheets
export function setPlanOptions(o) { planOpts = o; }
function content(p, id, scale) {
const u = scale / 96; // real inches per printed CSS pixel
if (id === 'site') return { svg: drawPlan(p, 'site', { u, print: true }), b: planBounds(p, 'site') };
if (id.startsWith('lvl:')) {
const [, l, sid] = id.split(':');
return { svg: drawPlan(p, +l, { u, print: true, only: sid, ...planOpts }), b: planBounds(p, +l, sid) };
}
const e = drawElevation(p, id.slice(5), { u, print: true });
return { svg: e.svg, b: e.bounds };
}
export function sheetScale(p, id, scale, paper) {
if (id !== 'site') return scale;
const P = PAPERS[paper], b = planBounds(p, 'site');
const aw = P.w - 2 * MARGIN, ah = P.h - 2 * MARGIN - TITLE;
return SITE_SCALES.find(sc => (b.x1 - b.x0) / sc <= aw && (b.y1 - b.y0) / sc <= ah) || SITE_SCALES.at(-1);
}
// Returns pages needed for a sheet at a scale; picks landscape or portrait, whichever needs fewer pages.
export function layout(p, id, scale, paper) {
const P = PAPERS[paper];
scale = sheetScale(p, id, scale, paper);
const c = content(p, id, scale);
const bw = (c.b.x1 - c.b.x0) / scale, bh = (c.b.y1 - c.b.y0) / scale; // paper inches
let best = null;
for (const portrait of [false, true]) {
const pw = portrait ? P.h : P.w, ph = portrait ? P.w : P.h;
const aw = pw - 2 * MARGIN, ah = ph - 2 * MARGIN - TITLE;
const cols = Math.max(1, Math.ceil(bw / aw - 0.001)), rows = Math.max(1, Math.ceil(bh / ah - 0.001));
if (!best || cols * rows < best.cols * best.rows) best = { c, aw, ah, cols, rows, bw, bh, P, scale, portrait };
}
return best;
}
export function fitsText(p, id, scale, paper) {
const L = layout(p, id, scale, paper);
const sz = `${L.bw.toFixed(1)} x ${L.bh.toFixed(1)} inches on paper`;
const at = id === 'site' ? `At ${siteLabel(L.scale)}: ` : '';
if (L.cols * L.rows === 1) return `${at}Fits on one page (${sz}).`;
return `${at}Needs ${L.cols * L.rows} pages, ${L.cols} across by ${L.rows} down (${sz}). Tape them together at the corner marks, or use tabloid paper or 1/8" = 1'.`;
}
function scaleBar(scale) {
// 1 paper inch = scale real inches; bar of 4 paper-inch-quarter ticks
const ft = scale / 12;
return `<svg width="2.2in" height="0.5in" viewBox="0 0 2.2 0.5" aria-hidden="true">
<rect x="0.05" y="0.05" width="0.5" height="0.12" fill="#000"/><rect x="0.55" y="0.05" width="0.5" height="0.12" fill="#fff" stroke="#000" stroke-width="0.01"/>
<text x="0.05" y="0.32" font-size="0.12">0</text><text x="1.05" y="0.32" font-size="0.12" text-anchor="middle">${ft} ft</text>
<text x="1.15" y="0.16" font-size="0.1">1 inch = ${ft} ft</text>
<rect x="1.15" y="0.22" width="0.25" height="0.25" fill="none" stroke="#000" stroke-width="0.008"/>
<text x="1.45" y="0.4" font-size="0.08">test: this box and the bar are exactly 1/4" and 1"</text></svg>`;
}
export function buildPrint(p, ids, scale, paper) {
const host = document.getElementById('printArea');
let html = '', pageNo = 0;
const pages = [];
for (const id of ids) {
const L = layout(p, id, scale, paper);
for (let r = 0; r < L.rows; r++) for (let c = 0; c < L.cols; c++) pages.push({ id, L, r, c });
}
const sheets = sheetList(p);
for (const pg of pages) {
pageNo++;
const { L, r, c, id } = pg, b = L.c.b, sc = L.scale;
const vw = L.aw * sc, vh = L.ah * sc;
// centre the drawing when it fits in one page
const ox = L.cols === 1 ? (b.x0 + b.x1) / 2 - vw / 2 : b.x0 + c * vw;
const top = L.rows === 1 ? (b.y0 + b.y1) / 2 + vh / 2 : b.y1 - r * vh;
const name = sheets.find(s => s.id === id)?.name || id;
const tile = L.cols * L.rows > 1 ? ` - part ${r * L.cols + c + 1} of ${L.cols * L.rows} (row ${r + 1}, column ${c + 1})` : '';
const marks = L.cols * L.rows > 1 ? `<path d="M${ox} ${-top}h${sc * 0.3}M${ox} ${-top}v${sc * 0.3}M${ox + vw} ${-top + vh}h${-sc * 0.3}M${ox + vw} ${-top + vh}v${-sc * 0.3}" stroke="#000" stroke-width="${sc / 96}"/>` : '';
html += `<section class="sheet" style="width:${L.aw}in;height:${L.ah + TITLE}in;page:${L.portrait ? 'tall' : 'wide'}">
<svg class="draw" width="${L.aw}in" height="${L.ah}in" viewBox="${ox} ${-top} ${vw} ${vh}" xmlns="http://www.w3.org/2000/svg" font-family="Arial, sans-serif">${L.c.svg}${marks}</svg>
<div class="titleblock"><div><b>${esc(p.name)}</b> - ${esc(name)}${tile}<br>Scale ${id === 'site' ? siteLabel(sc) : SCALES[sc].replace(' (default)', '')} · Print at "Actual size" / 100%, not "Fit to page". Page ${pageNo} of ${pages.length}.</div>${scaleBar(sc)}</div></section>`;
}
host.innerHTML = html;
let st = document.getElementById('pageStyle');
if (!st) { st = document.createElement('style'); st.id = 'pageStyle'; document.head.appendChild(st); }
const P = PAPERS[paper];
st.textContent = `@page { size: ${P.w}in ${P.h}in; margin: ${MARGIN}in; }
@page wide { size: ${P.w}in ${P.h}in; margin: ${MARGIN}in; }
@page tall { size: ${P.h}in ${P.w}in; margin: ${MARGIN}in; }`;
return pages.length;
}
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
EOF
furniture.js: the furniture and light fixture catalog with real sizes, the plan symbols, backing items up to a wall, and the furnished starter house.
cat > /root/build/furniture.js <<'EOF'
// Furniture and light fixtures at real sizes (inches), their plan symbols, and wall snapping.
// Items live in project.furniture:
// { id, s, lvl, type, name, x, y, rot, w, d, h, z, color, lm, k, on }
// x, y = centre of the item from its building's front-left corner; rot = degrees counter-clockwise.
// Item axes: u across the width, v from front (-) to back (+). At rot 0 the back faces north.
// z = bottom above the floor. Ceiling fixtures ignore z and hang h down from the ceiling.
import { extWalls, byId, wallShare } from './model.js';
export const FURN_CATS = {
living: 'Living room', bedroom: 'Bedroom', dining: 'Dining', kitchen: 'Kitchen',
bath: 'Bath and laundry', garage: 'Garage and shop', light: 'Light fixtures',
};
const WOOD = '#8a6442', CAB = '#f1eee8', STEEL = '#c9ccce', WHITE = '#f4f4f1';
// wall: backs up against a wall when placed; mount: floor (default), wall or ceiling.
// light: { kind: point | spot | rect, lm: lumens, angle: spot half-angle in degrees }
export const FURN = {
sofa: { cat: 'living', name: 'Sofa (3 seat)', w: 84, d: 38, h: 34, color: '#6f7f8f', sym: 'sofa', seats: 3, wall: true },
loveseat: { cat: 'living', name: 'Loveseat', w: 64, d: 38, h: 34, color: '#6f7f8f', sym: 'sofa', seats: 2, wall: true },
armchair: { cat: 'living', name: 'Armchair', w: 34, d: 36, h: 34, color: '#8a6d55', sym: 'sofa', seats: 1 },
recliner: { cat: 'living', name: 'Recliner', w: 36, d: 40, h: 40, color: '#5a4636', sym: 'sofa', seats: 1 },
coffee: { cat: 'living', name: 'Coffee table', w: 48, d: 24, h: 18, color: WOOD, sym: 'table' },
endtable: { cat: 'living', name: 'End table', w: 22, d: 22, h: 24, color: WOOD, sym: 'box', wall: true },
tvstand: { cat: 'living', name: 'TV stand with 65" TV', w: 60, d: 18, h: 24, color: '#3d3a36', sym: 'tv', wall: true },
bookshelf: { cat: 'living', name: 'Bookshelf', w: 36, d: 12, h: 72, color: WOOD, sym: 'shelf', wall: true },
king: { cat: 'bedroom', name: 'King bed', w: 76, d: 84, h: 24, color: '#9aa7b8', sym: 'bed', wall: true },
queen: { cat: 'bedroom', name: 'Queen bed', w: 60, d: 84, h: 24, color: '#9aa7b8', sym: 'bed', wall: true },
full: { cat: 'bedroom', name: 'Full bed', w: 54, d: 79, h: 23, color: '#b7a88f', sym: 'bed', wall: true },
twin: { cat: 'bedroom', name: 'Twin bed', w: 39, d: 79, h: 22, color: '#c49aa0', sym: 'bed', wall: true },
bunk: { cat: 'bedroom', name: 'Bunk beds (twin)', w: 42, d: 80, h: 64, color: '#c49aa0', sym: 'bed', wall: true },
crib: { cat: 'bedroom', name: 'Crib', w: 30, d: 54, h: 40, color: WHITE, sym: 'crib', wall: true },
nightstand: { cat: 'bedroom', name: 'Nightstand', w: 24, d: 18, h: 26, color: WOOD, sym: 'box', wall: true },
dresser: { cat: 'bedroom', name: 'Dresser', w: 60, d: 20, h: 34, color: WOOD, sym: 'box', wall: true },
chest: { cat: 'bedroom', name: 'Chest of drawers', w: 38, d: 20, h: 50, color: WOOD, sym: 'box', wall: true },
desk: { cat: 'bedroom', name: 'Desk', w: 48, d: 24, h: 30, color: WOOD, sym: 'table', wall: true },
officechair: { cat: 'bedroom', name: 'Desk chair', w: 26, d: 26, h: 40, color: '#2e2e2e', sym: 'chair' },
dining4: { cat: 'dining', name: 'Dining table, seats 4', w: 48, d: 36, h: 30, color: WOOD, sym: 'dtable', seats: 4 },
dining6: { cat: 'dining', name: 'Dining table, seats 6', w: 72, d: 40, h: 30, color: WOOD, sym: 'dtable', seats: 6 },
dining8: { cat: 'dining', name: 'Dining table, seats 8', w: 96, d: 42, h: 30, color: WOOD, sym: 'dtable', seats: 8 },
round4: { cat: 'dining', name: 'Round table 48", seats 4', w: 48, d: 48, h: 30, color: WOOD, sym: 'rtable', seats: 4 },
barstool: { cat: 'dining', name: 'Bar stool', w: 16, d: 16, h: 30, color: '#3d3a36', sym: 'stool' },
base24: { cat: 'kitchen', name: 'Base cabinet 24"', w: 24, d: 24, h: 36, color: CAB, sym: 'cab', wall: true },
base36: { cat: 'kitchen', name: 'Base cabinet 36"', w: 36, d: 24, h: 36, color: CAB, sym: 'cab', wall: true },
counter96: { cat: 'kitchen', name: 'Cabinets + counter run 8\'', w: 96, d: 24, h: 36, color: CAB, sym: 'cab', wall: true },
sink36: { cat: 'kitchen', name: 'Sink base 36" with sink', w: 36, d: 24, h: 36, color: CAB, sym: 'sink', wall: true },
upper30: { cat: 'kitchen', name: 'Wall cabinet 30"', w: 30, d: 12, h: 30, z: 54, color: CAB, sym: 'upper', wall: true, mount: 'wall' },
upper36: { cat: 'kitchen', name: 'Wall cabinet 36"', w: 36, d: 12, h: 30, z: 54, color: CAB, sym: 'upper', wall: true, mount: 'wall' },
pantry: { cat: 'kitchen', name: 'Pantry cabinet', w: 24, d: 24, h: 84, color: CAB, sym: 'appl', label: 'PAN', wall: true },
island: { cat: 'kitchen', name: 'Island with seating', w: 72, d: 36, h: 36, color: '#4d5b66', sym: 'island' },
fridge: { cat: 'kitchen', name: 'Refrigerator', w: 36, d: 30, h: 70, color: STEEL, sym: 'appl', label: 'REF', wall: true },
range: { cat: 'kitchen', name: 'Range 30"', w: 30, d: 26, h: 36, color: STEEL, sym: 'range', wall: true },
dw: { cat: 'kitchen', name: 'Dishwasher', w: 24, d: 24, h: 34, color: STEEL, sym: 'appl', label: 'DW', wall: true },
mwo: { cat: 'kitchen', name: 'Microwave over range', w: 30, d: 16, h: 17, z: 66, color: STEEL, sym: 'upper', label: 'MW', wall: true, mount: 'wall' },
toilet: { cat: 'bath', name: 'Toilet', w: 20, d: 28, h: 30, color: WHITE, sym: 'toilet', wall: true },
tub: { cat: 'bath', name: 'Bathtub 60"', w: 60, d: 30, h: 20, color: WHITE, sym: 'tub', wall: true },
shower: { cat: 'bath', name: 'Shower 36"', w: 36, d: 36, h: 80, color: WHITE, sym: 'shower', wall: true },
vanity30: { cat: 'bath', name: 'Vanity 30"', w: 30, d: 21, h: 34, color: '#e8e3da', sym: 'vanity', basins: 1, wall: true },
vanity60: { cat: 'bath', name: 'Double vanity 60"', w: 60, d: 22, h: 34, color: '#e8e3da', sym: 'vanity', basins: 2, wall: true },
washer: { cat: 'bath', name: 'Washer', w: 27, d: 30, h: 39, color: WHITE, sym: 'washer', label: 'W', wall: true },
dryer: { cat: 'bath', name: 'Dryer', w: 27, d: 30, h: 39, color: WHITE, sym: 'washer', label: 'D', wall: true },
wheater: { cat: 'bath', name: 'Water heater 50 gal', w: 22, d: 22, h: 60, color: WHITE, sym: 'round', label: 'WH', wall: true },
bench: { cat: 'garage', name: 'Workbench 6\'', w: 72, d: 30, h: 36, color: '#b58a5a', sym: 'bench', wall: true },
toolchest: { cat: 'garage', name: 'Rolling tool chest', w: 41, d: 24, h: 40, color: '#b0272a', sym: 'appl', label: 'TOOLS', wall: true },
shelving: { cat: 'garage', name: 'Steel shelving 4\'', w: 48, d: 18, h: 72, color: '#8d9196', sym: 'shelf', wall: true },
car: { cat: 'garage', name: 'Car (midsize sedan)', w: 73, d: 192, h: 57, color: '#3b5f8a', sym: 'car' },
suv: { cat: 'garage', name: 'SUV', w: 78, d: 198, h: 70, color: '#6e6e6e', sym: 'car', suv: true },
truck: { cat: 'garage', name: 'Pickup truck (full size)', w: 80, d: 232, h: 76, color: '#7a1f1f', sym: 'truck' },
can: { cat: 'light', name: 'Recessed can light 6"', w: 7, d: 7, h: 1, color: WHITE, sym: 'can', mount: 'ceiling', light: { kind: 'spot', lm: 650, angle: 55 } },
pendant: { cat: 'light', name: 'Pendant light', w: 14, d: 14, h: 30, color: '#2f3336', sym: 'pendant', mount: 'ceiling', light: { kind: 'point', lm: 800 } },
fan: { cat: 'light', name: 'Ceiling fan with light 52"', w: 52, d: 52, h: 14, color: '#6b4a30', sym: 'fan', mount: 'ceiling', light: { kind: 'point', lm: 1000 } },
chandelier: { cat: 'light', name: 'Chandelier', w: 28, d: 28, h: 30, color: '#3a3a3a', sym: 'chandelier', mount: 'ceiling', light: { kind: 'point', lm: 1600 } },
sconce: { cat: 'light', name: 'Wall sconce', w: 6, d: 6, h: 12, z: 62, color: '#2f3336', sym: 'sconce', mount: 'wall', wall: true, light: { kind: 'point', lm: 450 } },
shoplight: { cat: 'light', name: 'Garage shop light 4\' LED', w: 48, d: 6, h: 3, color: WHITE, sym: 'shoplight', mount: 'ceiling', light: { kind: 'rect', lm: 5000 }, kelvin: 5000 },
floorlamp: { cat: 'light', name: 'Floor lamp', w: 16, d: 16, h: 60, color: '#e8dcc4', sym: 'floorlamp', light: { kind: 'point', lm: 800 } },
};
export const KELVIN = { 2700: 'Warm white 2700K', 3000: 'Soft white 3000K', 4000: 'Neutral 4000K', 5000: 'Daylight 5000K' };
export const KELVIN_RGB = { 2700: '#ffc890', 3000: '#ffd4a6', 4000: '#ffe6cf', 5000: '#fff3e8' };
export const isLight = (f) => !!FURN[f.type]?.light;
export function newFurniture(type, s, lvl, x, y, rot = 0) {
const d = FURN[type];
const f = { id: Math.random().toString(36).slice(2, 10), s: s.id, lvl, type, name: d.name, x, y, rot,
w: d.w, d: d.d, h: d.h, z: d.z || 0, color: d.color };
if (d.light) Object.assign(f, { lm: d.light.lm, k: d.kelvin || 2700, on: true });
return f;
}
// Furnishes the starter 40' x 28' ranch + attached 2-car garage from newProject('house+garage').
export function sampleFurniture(p) {
const [h, g] = p.structures;
if (!h || !g) return;
const A = (s, type, x, y, rot = 0) => p.furniture.push(newFurniture(type, s, 0, x, y, rot));
// living room (x 16'-40', y 0-14')
A(h, 'sofa', 214, 84, 90); A(h, 'coffee', 262, 84, 90); A(h, 'tvstand', 464, 84, 270); A(h, 'armchair', 330, 30, 180);
// kitchen / dining (y 14'-28'): counters along the back wall, island, table
A(h, 'fridge', 212.5, 314.5); A(h, 'range', 249, 316.5); A(h, 'sink36', 288, 317.5); A(h, 'dw', 318, 317.5);
A(h, 'upper30', 249, 323.5); A(h, 'island', 290, 236, 180); A(h, 'dining6', 432, 240, 90);
// bedrooms and bath
A(h, 'queen', 48.5, 82, 90); A(h, 'nightstand', 15.5, 31, 90); A(h, 'nightstand', 15.5, 133, 90); A(h, 'dresser', 130, 143, 0);
A(h, 'twin', 150, 290); A(h, 'desk', 60, 231, 180);
A(h, 'tub', 36, 198); A(h, 'toilet', 91, 170, 270); A(h, 'vanity30', 30, 169.5, 180);
// lights
for (const [x, y] of [[264, 50], [408, 50], [264, 118], [408, 118], [56, 186], [150, 186]]) A(h, 'can', x, y);
A(h, 'pendant', 266, 236); A(h, 'pendant', 314, 236); A(h, 'chandelier', 432, 240);
A(h, 'fan', 110, 78); A(h, 'fan', 96, 276);
// garage: a car, an SUV, a workbench and two shop lights
A(g, 'car', 100, 144); A(g, 'suv', 196, 140); A(g, 'bench', 144, 266.5); A(g, 'shelving', 272.5, 150, 270);
A(g, 'shoplight', 100, 144, 90); A(g, 'shoplight', 196, 144, 90);
}
// Footprint size on the plan after rotation.
export function rotatedSize(f) {
const r = (f.rot || 0) * Math.PI / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
return [f.w * c + f.d * s, f.w * s + f.d * c];
}
// Back the item up against the closest wall within maxGap inches of its back edge (or any edge for
// a wall item), turning it to face away from the wall. Returns the wall description or null.
export function backToWall(p, f, maxGap = 30) {
const s = byId(p.structures, f.s);
if (!s) return null;
const cx = s.x + f.x, cy = s.y + f.y;
const lines = [];
for (const [k, w] of Object.entries(extWalls(s))) {
const sh = wallShare(p, s, k, f.lvl);
lines.push({ ...w, face: 6, ext: true, skip: sh.skip, label: { S: 'front wall', E: 'right wall', N: 'back wall', W: 'left wall' }[k] });
}
for (const w of p.walls.filter(w => w.s === s.id && w.lvl === f.lvl)) {
const a = [s.x + w.a[0], s.y + w.a[1]], b = [s.x + w.b[0], s.y + w.b[1]];
lines.push({ a, b, len: Math.hypot(b[0] - a[0], b[1] - a[1]), face: w.t / 2, ext: false, skip: [], label: 'inside wall' });
}
let best = null;
for (const L of lines) {
const dx = L.b[0] - L.a[0], dy = L.b[1] - L.a[1], len = L.len || 1;
const d = [dx / len, dy / len], n = [d[1], -d[0]];
const rx = cx - L.a[0], ry = cy - L.a[1];
const along = rx * d[0] + ry * d[1], across = rx * n[0] + ry * n[1];
if (along < -6 || along > len + 6) continue;
let face = L.face, side = -1; // side: -1 = item on the inner (negative normal) side
if (L.ext) { if (L.skip.some(([a, b]) => along > a && along < b)) face = 0; if (across > 0) continue; }
else side = across < 0 ? -1 : 1;
if (!L.ext) face = L.face;
const dist = Math.abs(across) - face - f.d / 2;
if (dist > maxGap) continue;
if (!best || dist < best.dist) best = { dist, L, d, n, along, side, face };
}
if (!best) return null;
const { L, d, n, along, side, face } = best;
const tv = [-side * n[0], -side * n[1]]; // from the item toward the wall
f.rot = Math.round(Math.atan2(-tv[0], tv[1]) * 180 / Math.PI + 360) % 360;
const off = face + f.d / 2 + 0.5;
const px = L.a[0] + d[0] * along - tv[0] * off, py = L.a[1] + d[1] * along - tv[1] * off;
f.x = Math.round((px - s.x) * 2) / 2; f.y = Math.round((py - s.y) * 2) / 2;
return L.label;
}
// ------------------------------------------------------------ plan symbols
const n2 = (v) => v.toFixed(2);
// SVG for one item, drawn in item axes inside a rotated group. opts: u, sel, print.
export function furnSvg(f, X, Y, opts) {
const d = FURN[f.type] || { sym: 'box' }, u = opts.u, sel = opts.sel;
const lightCol = opts.print ? '#000' : '#9a6500';
const col = sel ? '#d97a1a' : d.light ? lightCol : '#2a2a2a';
const st = `stroke="${col}" stroke-width="${n2((sel ? 2 : 0.9) * u)}"`;
const fillC = d.light ? (opts.print ? '#fff' : '#fff6cf') : '#fff';
const F = `fill="${fillC}" ${st}`, N = `fill="none" ${st}`, DASH = `${N} stroke-dasharray="${n2(4 * u)} ${n2(2.5 * u)}"`;
const W = f.w, D = f.d, hw = W / 2, hd = D / 2, rot = f.rot || 0;
const R = (u0, v0, u1, v1, a = F, rx = 0) =>
`<rect x="${n2(Math.min(u0, u1))}" y="${n2(-Math.max(v0, v1))}" width="${n2(Math.abs(u1 - u0))}" height="${n2(Math.abs(v1 - v0))}"${rx ? ` rx="${rx}"` : ''} ${a}/>`;
const C = (cu, cv, r, a = F) => `<circle cx="${n2(cu)}" cy="${n2(-cv)}" r="${n2(r)}" ${a}/>`;
const E = (cu, cv, rx, ry, a = F) => `<ellipse cx="${n2(cu)}" cy="${n2(-cv)}" rx="${n2(rx)}" ry="${n2(ry)}" ${a}/>`;
const L = (u0, v0, u1, v1, a = N) => `<line x1="${n2(u0)}" y1="${n2(-v0)}" x2="${n2(u1)}" y2="${n2(-v1)}" ${a}/>`;
const T = (label, cv = 0) => {
const size = Math.max(2.5, Math.min(8 * u, W / Math.max(2, label.length * 0.75), D / 2));
return `<text x="0" y="0" transform="translate(0 ${n2(-cv)}) rotate(${n2(rot)})" font-size="${n2(size)}" text-anchor="middle" dominant-baseline="middle" fill="${col}" pointer-events="none">${label}</text>`;
};
const chair = (cu, cv, ang) => `<g transform="translate(${n2(cu)} ${n2(-cv)}) rotate(${n2(-ang)})">${R(-9, -9, 9, 9, F, 2)}${L(-9, 7, 9, 7)}</g>`;
let s = '';
switch (d.sym) {
case 'sofa': {
const arm = Math.min(8, W * 0.16), back = Math.min(9, D * 0.26), n = d.seats || 1;
s += R(-hw, -hd, hw, hd, F, 2) + R(-hw, -hd, -hw + arm, hd, F, 2) + R(hw - arm, -hd, hw, hd, F, 2) + R(-hw + arm, hd - back, hw - arm, hd);
for (let i = 1; i < n; i++) { const x = -hw + arm + i * (W - 2 * arm) / n; s += L(x, -hd + 1, x, hd - back); }
break;
}
case 'bed': case 'crib': {
s += R(-hw, -hd, hw, hd) + R(-hw, hd - 3, hw, hd);
if (d.sym === 'crib') { s += R(-hw + 2, -hd + 2, hw - 2, hd - 2, N); break; }
if (W >= 50) s += R(-hw + 3, hd - 18, -2, hd - 6, F, 3) + R(2, hd - 18, hw - 3, hd - 6, F, 3);
else s += R(-hw + 4, hd - 18, hw - 4, hd - 6, F, 3);
s += L(-hw, hd - 26, hw, hd - 26) + L(hw - 16, hd - 26, hw, hd - 42);
break;
}
case 'table': s += R(-hw, -hd, hw, hd) + R(-hw + 2, -hd + 2, hw - 2, hd - 2, N); break;
case 'chair': s += C(0, 0, Math.min(hw, hd) - 1) + R(-hw + 3, hd - 5, hw - 3, hd, F, 2); break;
case 'stool': s += C(0, 0, hw); break;
case 'dtable': {
const n = d.seats || 4, ends = n >= 6 ? 2 : 0, perSide = (n - ends) / 2;
for (let i = 0; i < perSide; i++) {
const x = -hw + (i + 0.5) * W / perSide;
s += chair(x, -hd - 4, 0) + chair(x, hd + 4, 180);
}
if (ends) s += chair(-hw - 4, 0, -90) + chair(hw + 4, 0, 90);
s += R(-hw, -hd, hw, hd);
break;
}
case 'rtable': {
const n = d.seats || 4;
for (let i = 0; i < n; i++) { const a = (i / n) * 360 + 45, r = hw + 4, th = a * Math.PI / 180; s += chair(Math.cos(th) * r, Math.sin(th) * r, a + 90); }
s += C(0, 0, hw);
break;
}
case 'cab': s += R(-hw, -hd, hw, hd) + L(-hw, -hd + 1.5, hw, -hd + 1.5); break;
case 'sink': {
s += R(-hw, -hd, hw, hd) + L(-hw, -hd + 1.5, hw, -hd + 1.5);
if (W >= 33) s += R(-hw + 3, -hd + 3, -1, hd - 5, F, 2) + R(1, -hd + 3, hw - 3, hd - 5, F, 2);
else s += R(-hw + 4, -hd + 3, hw - 4, hd - 5, F, 2);
s += C(0, hd - 2.5, 1.2);
break;
}
case 'upper': s += R(-hw, -hd, hw, hd, DASH) + L(-hw, -hd, hw, hd, DASH) + (d.label ? T(d.label) : ''); break;
case 'island': s += R(-hw, -hd, hw, hd) + L(-hw, hd - 12, hw, hd - 12, DASH); break;
case 'appl': s += R(-hw, -hd, hw, hd) + L(-hw, -hd + 1.5, hw, -hd + 1.5) + T(d.label || ''); break;
case 'washer': s += R(-hw, -hd, hw, hd) + C(0, -2, Math.min(hw, hd) - 5, N) + T(d.label || '', -2); break;
case 'range': {
s += R(-hw, -hd, hw, hd) + R(-hw, hd - 3, hw, hd);
for (const [a, b, r] of [[-7, -6, 4], [7, -6, 4], [-7, 6, 3.2], [7, 6, 3.2]]) s += C(a, b - 1, r, N);
break;
}
case 'toilet': s += R(-hw + 1, hd - 8, hw - 1, hd, F, 2) + E(0, hd - 8 - 9.5, hw - 2, 10.5); break;
case 'tub': s += R(-hw, -hd, hw, hd) + R(-hw + 3, -hd + 3, hw - 3, hd - 3, N, 8) + C(-hw + 8, 0, 1.3, N); break;
case 'shower': s += R(-hw, -hd, hw, hd) + L(-hw, -hd, hw, hd) + L(-hw, hd, hw, -hd) + C(0, 0, 2); break;
case 'vanity': {
s += R(-hw, -hd, hw, hd);
const n = d.basins || 1;
for (let i = 0; i < n; i++) { const x = -hw + (i + 0.5) * W / n; s += E(x, -1, Math.min(8, W / n / 2 - 3), 6); }
break;
}
case 'round': s += C(0, 0, Math.min(hw, hd)) + T(d.label || ''); break;
case 'bench': s += R(-hw, -hd, hw, hd) + L(-hw, hd - 2, hw, hd - 2) + R(-hw + 2, -hd - 2, -hw + 8, -hd + 4); break;
case 'shelf': s += R(-hw, -hd, hw, hd) + L(-hw, -hd, hw, hd) + L(-hw, hd, hw, -hd); break;
case 'tv': s += R(-hw, -hd, hw, hd) + R(-28.5, hd - 7, 28.5, hd - 4, `fill="#333" ${st}`); break;
case 'car': case 'truck': {
const v = (t) => -hd + t * D; // t = 0 front bumper .. 1 rear bumper
s += R(-hw - 3, v(0.34), -hw, v(0.38)) + R(hw, v(0.34), hw + 3, v(0.38)); // mirrors
s += R(-hw + 1, -hd, hw - 1, hd, F, Math.min(14, W / 5));
if (d.sym === 'truck') {
s += R(-hw + 7, v(0.27), hw - 7, v(0.55), N, 4) + L(-hw + 5, v(0.2), hw - 5, v(0.2));
s += R(-hw + 4, v(0.58), hw - 4, hd - 3, N, 2) + L(-hw + 4, v(0.58), hw - 4, v(0.58));
} else {
const back = d.suv ? 0.9 : 0.78;
s += `<polygon points="${n2(-hw + 6)},${n2(-v(0.27))} ${n2(hw - 6)},${n2(-v(0.27))} ${n2(hw - 9)},${n2(-v(0.38))} ${n2(-hw + 9)},${n2(-v(0.38))}" ${N}/>`;
s += R(-hw + 9, v(0.38), hw - 9, v(back - 0.06), N, 4) + L(-hw + 8, v(back), hw - 8, v(back));
}
s += T(d.sym === 'truck' ? 'TRUCK' : d.suv ? 'SUV' : 'CAR', 0);
break;
}
case 'can': s += C(0, 0, hw) + C(0, 0, hw * 0.55, N); break;
case 'pendant': s += C(0, 0, hw) + L(-hw * 0.7, -hw * 0.7, hw * 0.7, hw * 0.7) + L(-hw * 0.7, hw * 0.7, hw * 0.7, -hw * 0.7); break;
case 'fan': {
for (let i = 0; i < 5; i++) s += `<g transform="rotate(${i * 72})">${E(hw * 0.55, 0, hw * 0.42, 2.6, N)}</g>`;
s += C(0, 0, 5);
break;
}
case 'chandelier': {
s += C(0, 0, hw, N);
for (let i = 0; i < 6; i++) { const th = i * Math.PI / 3; s += C(Math.cos(th) * hw * 0.7, Math.sin(th) * hw * 0.7, 2); }
s += C(0, 0, 3);
break;
}
case 'sconce': s += R(-hw, hd - 1.5, hw, hd) + E(0, hd - 1.5, hw * 0.8, 3.5); break;
case 'shoplight': s += R(-hw, -hd, hw, hd) + L(-hw + 2, 0, hw - 2, 0); break;
case 'floorlamp': s += C(0, 0, hw) + C(0, 0, 2.5, N); break;
default: s += R(-hw, -hd, hw, hd);
}
// generous invisible hit area so thin symbols are easy to grab
const pad = d.sym === 'dtable' || d.sym === 'rtable' ? 14 : 3;
s += R(-hw - pad, -hd - pad, hw + pad, hd + pad, 'fill="transparent" stroke="none"');
return `<g transform="translate(${n2(X)} ${n2(-Y)}) rotate(${n2(-rot)})" data-kind="furn" data-id="${f.id}" style="cursor:move">${s}</g>`;
}
EOF
furniture3d.js: the 3D furniture models and the real lights inside the fixtures.
cat > /root/build/furniture3d.js <<'EOF'
// Procedural 3D models for the furniture library, plus real lights for the fixtures.
// A model is built in item axes (u across, v front to back, z up) with its origin at the centre
// of its footprint, on its bottom. View3D places and turns it. Units are inches.
import * as THREE from 'three';
import { FURN, KELVIN_RGB } from './furniture.js';
const cache = new Map();
function mat(color, rough = 0.7, metal = 0, extra = {}) {
const key = [color, rough, metal, JSON.stringify(extra)].join('|');
if (!cache.has(key)) cache.set(key, new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal, ...extra }));
return cache.get(key);
}
const STEEL = () => mat('#c3c6c9', 0.3, 0.75), BLACK = () => mat('#1d1e20', 0.4), RUBBER = () => mat('#1a1a1a', 0.9);
const PORC = () => mat('#f7f7f4', 0.12), WOOD = () => mat('#8a6442', 0.55), COUNTER = () => mat('#d9d3c8', 0.3);
const GLASS = () => mat('#161b22', 0.05, 0.5, { transparent: true, opacity: 0.88 });
// How bright a fixture looks relative to the sun. Tuned so a room lit at night reads like a lit room.
export const LIGHT_K = 0.045;
export function furnitureModel(f, opts = {}) {
const d = FURN[f.type];
const g = new THREE.Group();
const hw = f.w / 2, hd = f.d / 2, H = f.h, C = mat(f.color, 0.85);
const shadow = (m) => { m.castShadow = true; m.receiveShadow = true; g.add(m); return m; };
const box = (u0, u1, v0, v1, z0, z1, m, parent) => {
const geo = new THREE.BoxGeometry(Math.abs(u1 - u0) || 0.01, Math.abs(z1 - z0) || 0.01, Math.abs(v1 - v0) || 0.01);
const mesh = new THREE.Mesh(geo, m);
mesh.position.set((u0 + u1) / 2, (z0 + z1) / 2, -(v0 + v1) / 2);
mesh.castShadow = mesh.receiveShadow = true;
(parent || g).add(mesh);
return mesh;
};
const cyl = (cu, cv, rBottom, rTop, z0, z1, m, seg = 24, parent) => {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rTop, rBottom, z1 - z0, seg), m);
mesh.position.set(cu, (z0 + z1) / 2, -cv);
mesh.castShadow = mesh.receiveShadow = true;
(parent || g).add(mesh);
return mesh;
};
const legs = (u0, u1, v0, v1, z1, t, m) => { for (const u of [u0 + t / 2, u1 - t / 2]) for (const v of [v0 + t / 2, v1 - t / 2]) box(u - t / 2, u + t / 2, v - t / 2, v + t / 2, 0, z1, m); };
const chair = (cu, cv, ang) => {
const c = new THREE.Group(); c.position.set(cu, 0, -cv); c.rotation.y = ang * Math.PI / 180; g.add(c);
const w = WOOD();
for (const u of [-7.5, 7.5]) for (const v of [-7.5, 7.5]) box(u - 0.8, u + 0.8, v - 0.8, v + 0.8, 0, 17, w, c);
box(-8.5, 8.5, -8.5, 8.5, 17, 18.5, mat(f.color, 0.5), c);
box(-8.5, 8.5, 7, 8.5, 18.5, 34, w, c);
};
// side profile (v, z pairs) extruded across the width: the body of a car or truck
const extrude = (pts, width, m, u0 = 0) => {
const sh = new THREE.Shape(pts.map(([v, z]) => new THREE.Vector2(v, z)));
const geo = new THREE.ExtrudeGeometry(sh, { depth: width, bevelEnabled: true, bevelThickness: 1.5, bevelSize: 1.5, bevelSegments: 2, steps: 1 });
geo.rotateY(Math.PI / 2); geo.translate(u0 - width / 2, 0, 0);
return shadow(new THREE.Mesh(geo, m));
};
const wheel = (u, v, r, wdt) => {
const t = cyl(0, 0, r, r, -wdt / 2, wdt / 2, RUBBER(), 24);
t.rotation.z = Math.PI / 2; t.position.set(u, r, -v);
const hub = cyl(0, 0, r * 0.55, r * 0.55, -wdt / 2 - 0.3, wdt / 2 + 0.3, STEEL(), 16);
hub.rotation.z = Math.PI / 2; hub.position.set(u, r, -v);
};
const fix = { lights: [], glow: [] };
const glowMat = () => {
const m = new THREE.MeshStandardMaterial({ color: '#fffaf0', emissive: KELVIN_RGB[f.k] || '#ffd4a6', emissiveIntensity: 0, roughness: 0.4 });
fix.glow.push(m); return m;
};
const lm = f.on === false ? 0 : (f.lm ?? d.light?.lm ?? 0), lcol = KELVIN_RGB[f.k] || KELVIN_RGB[2700];
const pointLight = (u, v, z) => {
const L = new THREE.PointLight(lcol, 0, 40 * 12, 2);
L.position.set(u, z, -v);
L.shadow.mapSize.set(512, 512); L.shadow.camera.near = 1; L.shadow.camera.far = 600; L.shadow.bias = -0.002; L.shadow.normalBias = 0.6;
g.add(L); fix.lights.push([L, lm / (4 * Math.PI) * 1550 * LIGHT_K]);
};
switch (d.sym) {
case 'sofa': {
const arm = Math.min(8, f.w * 0.16), back = Math.min(9, f.d * 0.26), n = d.seats || 1, seatZ = Math.min(18, H * 0.5);
for (const u of [-hw + 2, hw - 2]) for (const v of [-hd + 2, hd - 2]) box(u - 1.2, u + 1.2, v - 1.2, v + 1.2, 0, 3, BLACK());
box(-hw, hw, -hd, hd, 3, seatZ - 4, C);
const sw = (f.w - 2 * arm) / n;
for (let i = 0; i < n; i++) box(-hw + arm + i * sw + 0.3, -hw + arm + (i + 1) * sw - 0.3, -hd + 0.5, hd - back, seatZ - 4, seatZ, C);
box(-hw + arm, hw - arm, hd - back, hd, seatZ - 4, H, C);
box(-hw, -hw + arm, -hd, hd, 3, H - 9, C); box(hw - arm, hw, -hd, hd, 3, H - 9, C);
break;
}
case 'bed': case 'crib': {
const w = WOOD();
if (d.sym === 'crib') {
box(-hw, hw, -hd, hd, 10, 14, mat(f.color, 0.6));
box(-hw + 1, hw - 1, -hd + 1, hd - 1, 14, 18, mat('#f1f1f1', 0.95));
for (const v of [-hd, hd - 1.5]) box(-hw, hw, v, v + 1.5, 0, H, mat(f.color, 0.6));
for (let v = -hd + 3; v < hd - 2; v += 3.5) for (const u of [-hw + 0.5, hw - 0.5]) box(u - 0.5, u + 0.5, v - 0.5, v + 0.5, 14, H, mat(f.color, 0.6));
box(-hw, -hw + 1, -hd, hd, H - 1.5, H, mat(f.color, 0.6)); box(hw - 1, hw, -hd, hd, H - 1.5, H, mat(f.color, 0.6));
break;
}
const mattTop = d.name.startsWith('Bunk') ? 16 : H, frameTop = Math.max(8, mattTop - 12);
const tier = (z0) => {
box(-hw, hw, -hd, hd - 2, z0 + 4, z0 + frameTop, w);
box(-hw + 1, hw - 1, -hd + 1, hd - 3, z0 + frameTop, z0 + mattTop, mat('#f3f0ea', 0.95));
box(-hw + 0.5, hw - 0.5, -hd + 0.5, hd - 26, z0 + mattTop - 6, z0 + mattTop + 0.8, C);
const pw = f.w >= 50 ? (f.w - 9) / 2 : f.w - 8;
for (let i = 0; i < (f.w >= 50 ? 2 : 1); i++) {
const u0 = -hw + 3 + i * (pw + 3);
box(u0, u0 + pw, hd - 18, hd - 6, z0 + mattTop, z0 + mattTop + 5, mat('#ffffff', 0.9));
}
};
tier(0);
if (d.name.startsWith('Bunk')) { tier(H - 20); for (const u of [-hw, hw - 2]) for (const v of [-hd, hd - 2]) box(u, u + 2, v, v + 2, 0, H + 6, w); }
else { box(-hw, hw, hd - 3, hd, 0, 48, w); for (const u of [-hw, hw - 2]) box(u, u + 2, -hd, -hd + 2, 0, 14, w); }
break;
}
case 'table': {
const m = mat(f.color, 0.5);
box(-hw, hw, -hd, hd, H - 1.5, H, m); legs(-hw + 1, hw - 1, -hd + 1, hd - 1, H - 1.5, 2, m);
if (f.type === 'coffee') box(-hw + 2, hw - 2, -hd + 2, hd - 2, 5, 6, m);
break;
}
case 'box': {
const m = mat(f.color, 0.5);
box(-hw + 0.5, hw - 0.5, -hd + 0.5, hd, 3, H - 1, m); box(-hw, hw, -hd, hd, H - 1, H, m);
legs(-hw + 1, hw - 1, -hd + 1, hd - 1, 3, 1.5, m);
const rows = Math.max(1, Math.round((H - 4) / 9));
for (let i = 1; i < rows; i++) box(-hw + 1, hw - 1, -hd + 0.3, -hd + 0.5, 3 + i * (H - 4) / rows - 0.2, 3 + i * (H - 4) / rows + 0.2, BLACK());
for (let i = 0; i < rows; i++) box(-2, 2, -hd - 0.6, -hd + 0.5, 3 + (i + 0.5) * (H - 4) / rows - 0.4, 3 + (i + 0.5) * (H - 4) / rows + 0.4, STEEL());
break;
}
case 'chair': {
cyl(0, 0, hw - 1, hw - 1, 0, 1, BLACK(), 5); cyl(0, 0, 1.2, 1.2, 1, 17, STEEL(), 12);
box(-hw + 3, hw - 3, -hd + 3, hd - 3, 17, 20, C); box(-hw + 4, hw - 4, hd - 5, hd - 2, 20, H, C);
break;
}
case 'stool': cyl(0, 0, hw, hw, H - 2, H, C); for (const a of [0, 1, 2, 3]) { const t = a * Math.PI / 2 + Math.PI / 4; box(Math.cos(t) * (hw - 3) - 0.6, Math.cos(t) * (hw - 3) + 0.6, Math.sin(t) * (hw - 3) - 0.6, Math.sin(t) * (hw - 3) + 0.6, 0, H - 2, BLACK()); } break;
case 'dtable': case 'rtable': {
const m = mat(f.color, 0.45), n = d.seats || 4;
if (d.sym === 'rtable') {
cyl(0, 0, hw, hw, H - 1.5, H, m, 40); cyl(0, 0, 3, 3, 2, H - 1.5, m); cyl(0, 0, 10, 10, 0, 2, m);
for (let i = 0; i < n; i++) { const a = (i / n) * 360 + 45, th = a * Math.PI / 180; chair(Math.cos(th) * (hw + 4), Math.sin(th) * (hw + 4), a + 90); }
} else {
box(-hw, hw, -hd, hd, H - 1.5, H, m); legs(-hw + 2, hw - 2, -hd + 2, hd - 2, H - 1.5, 3, m);
const ends = n >= 6 ? 2 : 0, per = (n - ends) / 2;
for (let i = 0; i < per; i++) { const x = -hw + (i + 0.5) * f.w / per; chair(x, -hd - 4, 0); chair(x, hd + 4, 180); }
if (ends) { chair(-hw - 4, 0, -90); chair(hw + 4, 0, 90); }
}
break;
}
case 'cab': case 'sink': case 'vanity': case 'island': {
const isl = d.sym === 'island', back = isl ? hd - 12 : hd, top = H - 1.5;
box(-hw, hw, -hd + 3, back, 0, 4, BLACK());
box(-hw, hw, -hd, back, 4, top, C);
const ct = d.sym === 'vanity' ? PORC() : COUNTER();
box(-hw - (isl ? 1 : 0), hw + (isl ? 1 : 0), -hd - 1, hd + (isl ? 1 : 0), top, H, ct);
const nd = Math.max(1, Math.round(f.w / 18));
for (let i = 1; i < nd; i++) { const x = -hw + i * f.w / nd; box(x - 0.15, x + 0.15, -hd - 0.1, -hd + 0.2, 4, top, BLACK()); }
box(-hw, hw, -hd - 0.1, -hd + 0.2, top - 6.2, top - 5.8, BLACK());
for (let i = 0; i < nd; i++) { const x = -hw + (i + 0.5) * f.w / nd; box(x - 2, x + 2, -hd - 0.8, -hd, top - 3.4, top - 2.6, STEEL()); }
if (d.sym === 'sink') {
const dbl = f.w >= 33;
for (const [a, b] of dbl ? [[-hw + 3, -1], [1, hw - 3]] : [[-hw + 4, hw - 4]]) box(a, b, -hd + 3, hd - 5, H, H + 0.1, mat('#8e9398', 0.25, 0.8));
cyl(0, hd - 2.5, 0.8, 0.8, H, H + 12, STEEL(), 12); box(-0.6, 0.6, hd - 9, hd - 2.5, H + 11, H + 12.2, STEEL());
}
if (d.sym === 'vanity') {
const n = d.basins || 1;
for (let i = 0; i < n; i++) {
const x = -hw + (i + 0.5) * f.w / n;
const b = cyl(x, -1, 7, 7, H, H + 0.15, mat('#e2e2df', 0.15), 28); b.scale.z = 6 / 7;
cyl(x, hd - 3, 0.6, 0.6, H, H + 8, STEEL(), 12); box(x - 0.5, x + 0.5, hd - 7, hd - 3, H + 7.2, H + 8.2, STEEL());
}
box(-hw, hw, hd - 1, hd, H, H + 32, mat('#a9bccb', 0.03, 0.9)); // mirror
}
if (isl) for (let i = 0; i < Math.floor(f.w / 24); i++) {
const x = -hw + (i + 0.5) * f.w / Math.floor(f.w / 24);
cyl(x, hd - 6, 7, 7, 26, 28, BLACK()); cyl(x, hd - 6, 1, 1, 0, 26, STEEL(), 10); cyl(x, hd - 6, 7, 7, 0, 1, STEEL());
}
break;
}
case 'upper': {
box(-hw, hw, -hd, hd, 0, H, C);
if (d.label === 'MW') { box(-hw + 2, hw - 9, -hd - 0.1, -hd + 0.2, 3, H - 3, BLACK()); box(hw - 7, hw - 2, -hd - 0.1, -hd + 0.2, 3, H - 3, mat('#2a2a2a', 0.3)); break; }
const nd = Math.max(1, Math.round(f.w / 18));
for (let i = 1; i < nd; i++) { const x = -hw + i * f.w / nd; box(x - 0.15, x + 0.15, -hd - 0.1, -hd + 0.2, 0, H, BLACK()); }
for (let i = 0; i < nd; i++) { const x = -hw + (i + 0.5) * f.w / nd; box(x - 0.4, x + 0.4, -hd - 0.8, -hd, 2, 6, STEEL()); }
break;
}
case 'appl': {
const m = mat(f.color, f.color === '#c9ccce' ? 0.3 : 0.5, f.color === '#c9ccce' ? 0.7 : 0.1);
box(-hw, hw, -hd, hd, 0, H, m);
if (d.label === 'REF') {
box(-0.15, 0.15, -hd - 0.1, -hd + 0.2, H * 0.32, H, BLACK()); box(-hw, hw, -hd - 0.1, -hd + 0.2, H * 0.32 - 0.2, H * 0.32 + 0.2, BLACK());
for (const x of [-2, 2]) box(x - 0.5, x + 0.5, -hd - 2, -hd, H * 0.45, H * 0.85, STEEL());
box(-hw + 6, hw - 6, -hd - 2, -hd, H * 0.26, H * 0.28, STEEL());
} else if (d.label === 'DW') {
box(-hw + 1, hw - 1, -hd - 1.5, -hd, H - 4, H - 3, STEEL()); box(-hw, hw, -hd - 0.1, -hd + 0.2, H - 6, H - 5.6, BLACK());
} else if (d.label === 'TOOLS') {
for (let z = 6; z < H - 2; z += 4) { box(-hw, hw, -hd - 0.1, -hd + 0.2, z - 0.15, z + 0.15, BLACK()); box(-hw + 4, hw - 4, -hd - 1, -hd, z + 1.4, z + 2, STEEL()); }
for (const u of [-hw + 3, hw - 3]) for (const v of [-hd + 3, hd - 3]) cyl(u, v, 2, 2, -0.01, 2, RUBBER(), 12);
} else {
box(-0.15, 0.15, -hd - 0.1, -hd + 0.2, 4, H, BLACK()); box(hw - 4, hw - 3, -hd - 1.5, -hd, H * 0.5, H * 0.75, STEEL());
}
break;
}
case 'range': {
box(-hw, hw, -hd, hd - 3, 0, H - 0.5, STEEL());
box(-hw, hw, -hd, hd - 3, H - 0.5, H, BLACK());
box(-hw, hw, hd - 3, hd, 0, H + 8, STEEL());
box(-hw + 3, hw - 3, -hd - 0.1, -hd + 0.3, 8, 24, mat('#151618', 0.1, 0.4));
box(-hw + 3, hw - 3, -hd - 1.8, -hd, 26, 27, STEEL());
for (const [a, b, r] of [[-7, -6, 4], [7, -6, 4], [-7, 6, 3.2], [7, 6, 3.2]]) cyl(a, b - 1.5, r, r, H, H + 0.1, mat('#3a3b3d', 0.5), 24);
break;
}
case 'toilet': {
const p = PORC();
box(-hw + 1, hw - 1, hd - 8, hd, 15, 29, p); box(-hw + 0.5, hw - 0.5, hd - 8.5, hd, 29, 30, p);
const base = cyl(0, hd - 8 - 7, 5, 6, 0, 10, p, 28); base.scale.x = 1.2;
const bowl = cyl(0, hd - 8 - 9.5, 6.5, hw - 2, 10, 15, p, 32); bowl.scale.z = 10.5 / (hw - 2);
const seat = cyl(0, hd - 8 - 9.5, hw - 1.5, hw - 1.5, 15, 16, p, 32); seat.scale.z = 11 / (hw - 1.5);
break;
}
case 'tub': {
const p = PORC();
box(-hw, hw, -hd, hd, 0, 3, p);
box(-hw, hw, -hd, -hd + 3, 3, H, p); box(-hw, hw, hd - 3, hd, 3, H, p);
box(-hw, -hw + 3, -hd + 3, hd - 3, 3, H, p); box(hw - 3, hw, -hd + 3, hd - 3, 3, H, p);
cyl(-hw + 8, 0, 1.3, 1.3, 3, 3.1, STEEL(), 12);
cyl(-hw + 1.5, 0, 0.7, 0.7, H, H + 6, STEEL(), 10); box(-hw + 1, -hw + 6, -0.6, 0.6, H + 5, H + 6, STEEL());
break;
}
case 'shower': {
box(-hw, hw, -hd, hd, 0, 3, PORC());
const gl = mat('#bcd4e0', 0.02, 0.1, { transparent: true, opacity: 0.25 });
box(-hw, hw, -hd, -hd + 0.4, 3, 76, gl); box(hw - 0.4, hw, -hd, hd, 3, 76, gl);
box(-hw, hw, -hd, -hd + 0.8, 75, 76, STEEL());
cyl(0, hd - 1, 0.6, 0.6, 66, 74, STEEL(), 10); cyl(0, hd - 4, 3, 3, 72, 73, STEEL(), 20);
cyl(0, 0, 2, 2, 3, 3.1, STEEL(), 12);
break;
}
case 'washer': {
box(-hw, hw, -hd, hd, 0, H, mat(f.color, 0.35));
box(-hw, hw, hd - 5, hd, H, H + 5, mat(f.color, 0.35));
const door = cyl(0, -hd - 0.5, 9, 9, 0, 1, mat('#cfd3d6', 0.3, 0.6), 32); door.rotation.x = Math.PI / 2; door.position.set(0, H * 0.45, hd + 0.5);
const win = cyl(0, 0, 6.5, 6.5, 0, 1, GLASS(), 32); win.rotation.x = Math.PI / 2; win.position.set(0, H * 0.45, hd + 0.9);
for (const x of [-hw + 4, -hw + 8]) { const k = cyl(0, 0, 1.2, 1.2, 0, 1, BLACK(), 12); k.rotation.x = Math.PI / 2; k.position.set(x, H + 2.5, -(hd - 5) + 0.5); }
break;
}
case 'round': cyl(0, 0, hw, hw, 0, H, mat(f.color, 0.4), 32); cyl(0, 0, hw * 0.3, hw * 0.3, H, H + 2, STEEL()); for (const x of [-5, 5]) cyl(x, 0, 0.7, 0.7, H, H + 10, mat('#b87333', 0.35, 0.8), 10); break;
case 'bench': {
const m = mat(f.color, 0.55);
box(-hw, hw, -hd, hd, H - 2, H, m); legs(-hw + 1, hw - 1, -hd + 1, hd - 1, H - 2, 3.5, m);
box(-hw + 1, hw - 1, -hd + 2, hd - 2, 6, 7, m);
box(-hw, hw, hd - 0.75, hd, H, H + 36, mat('#9a7b55', 0.85));
for (let u = -hw + 6; u < hw - 3; u += 7) box(u - 1.5, u + 1.5, hd - 3, hd - 0.75, H + 18, H + 19, mat('#b0272a', 0.4));
box(-hw + 2, -hw + 8, -hd - 2, -hd + 4, H, H + 4, mat('#2b4a7a', 0.4, 0.4));
break;
}
case 'shelf': {
const m = mat(f.color, 0.5, f.type === 'shelving' ? 0.6 : 0);
const steel = f.type === 'shelving';
if (steel) for (const u of [-hw + 0.75, hw - 0.75]) for (const v of [-hd + 0.75, hd - 0.75]) box(u - 0.75, u + 0.75, v - 0.75, v + 0.75, 0, H, m);
else { box(-hw, -hw + 0.75, -hd, hd, 0, H, m); box(hw - 0.75, hw, -hd, hd, 0, H, m); box(-hw, hw, hd - 0.5, hd, 0, H, m); }
const n = steel ? 5 : 6;
const bookCols = ['#7a2e22', '#2b4a7a', '#2f5d3a', '#b08a3a', '#5a3d6a', '#3a3a3a'];
for (let i = 0; i < n; i++) {
const z = 2 + i * (H - 3) / (n - 1);
box(-hw + (steel ? 0 : 0.75), hw - (steel ? 0 : 0.75), -hd, hd - 0.5, z - 0.75, z, m);
if (i < n - 1) {
if (steel) { box(-hw + 3, -hw + 17, -hd + 2, hd - 2, z, z + 10, mat('#c9a66b', 0.9)); box(2, hw - 4, -hd + 3, hd - 3, z, z + 8, mat('#3d6a8a', 0.6)); }
else for (let u = -hw + 1.5, j = 0; u < hw - 3; j++) { const bw = 1 + ((j * 7) % 3) * 0.6; box(u, u + bw, -hd + 2, hd - 1, z, z + 7 + (j % 3), mat(bookCols[(i + j) % 6], 0.8)); u += bw + 0.1; if (j % 9 === 8) u += 5; }
}
}
break;
}
case 'tv': {
const m = mat(f.color, 0.5);
box(-hw, hw, -hd, hd, 2, H, m); legs(-hw + 1, hw - 1, -hd + 1, hd - 1, 2, 2, BLACK());
box(-hw + 2, hw - 2, -hd - 0.1, -hd + 0.2, H / 2 - 0.2, H / 2 + 0.2, BLACK());
box(-6, 6, hd - 9, hd - 4, H, H + 1, BLACK()); box(-1.5, 1.5, hd - 6, hd - 5, H + 1, H + 4, BLACK());
box(-28.5, 28.5, hd - 6.5, hd - 5, H + 3, H + 35, mat('#0b0c0e', 0.15, 0.3));
break;
}
case 'car': case 'truck': {
const paint = mat(f.color, 0.25, 0.5), V = (t) => -hd + t * f.d, r = f.h > 65 ? 15 : 13;
if (d.sym === 'truck') {
extrude([[V(0), 12], [V(0), 38], [V(0.03), 44], [V(0.24), 47], [V(0.58), 47], [V(0.58), 30], [V(1), 30], [V(1), 12]], f.w - 4, paint);
extrude([[V(0.26), 47], [V(0.35), H - 1], [V(0.55), H - 1], [V(0.565), 47]], f.w - 10, GLASS());
box(-hw + 6, hw - 6, V(0.36), V(0.55), H - 0.5, H + 0.8, paint);
box(-hw + 2, hw - 2, V(0.58), V(1), 30, 31, mat('#2b2b2b', 0.8));
for (const u of [-hw + 2, hw - 3.5]) box(u, u + 1.5, V(0.585), V(1), 30, 50, paint);
box(-hw + 2, hw - 2, V(0.98), V(1), 30, 50, paint); box(-hw + 2, hw - 2, V(0.585), V(0.6), 30, 50, paint);
} else {
const roofEnd = d.suv ? 0.93 : 0.78, belt = d.suv ? 40 : 36;
extrude([[V(0), 9], [V(0), 26], [V(0.04), 30], [V(0.3), belt - 1], [V(0.97), belt], [V(1), 28], [V(1), 9]], f.w - 4, paint);
extrude([[V(0.3), belt - 1], [V(0.44), H - 1.5], [V(roofEnd - 0.06), H - 1.5], [V(roofEnd + 0.04), belt]], f.w - 9, GLASS());
box(-hw + 6, hw - 6, V(0.45), V(roofEnd - 0.05), H - 0.5, H + 0.8, paint);
}
const front = d.sym === 'truck' ? 0.17 : 0.17, rear = d.sym === 'truck' ? 0.78 : 0.8;
for (const t of [front, rear]) for (const u of [-hw + 4.5, hw - 4.5]) wheel(u, V(t), r, 8);
for (const u of [-hw + 8, hw - 8]) {
box(u - 5, u + 5, -hd - 0.3, -hd + 1, 26, 30, mat('#fffbe8', 0.1, 0, { emissive: '#fffbe8', emissiveIntensity: 0.2 }));
box(u - 5, u + 5, hd - 1, hd + 0.3, 26, 30, mat('#9a1010', 0.2, 0, { emissive: '#600', emissiveIntensity: 0.3 }));
}
box(-hw + 10, hw - 10, -hd - 0.5, -hd + 1, 14, 22, BLACK());
break;
}
// ---------------------------------------------------- light fixtures
case 'can': {
cyl(0, 0, hw, hw, H - 0.6, H, mat('#ffffff', 0.4), 24);
cyl(0, 0, hw * 0.72, hw * 0.72, H - 0.75, H - 0.6, glowMat(), 24);
const ang = (d.light.angle || 55) * Math.PI / 180;
const L = new THREE.SpotLight(lcol, 0, 40 * 12, ang, 0.65, 2);
L.position.set(0, H - 1, 0); L.target.position.set(0, -200, 0);
L.shadow.mapSize.set(1024, 1024); L.shadow.camera.near = 2; L.shadow.camera.far = 600; L.shadow.bias = -0.0015; L.shadow.normalBias = 0.6;
g.add(L, L.target);
fix.lights.push([L, lm / (2 * Math.PI * (1 - Math.cos(ang))) * 1550 * LIGHT_K]);
break;
}
case 'pendant': {
const shadeH = Math.min(10, H * 0.4);
cyl(0, 0, 2.5, 2.5, H - 0.8, H, mat(f.color, 0.4, 0.5), 16);
cyl(0, 0, 0.15, 0.15, shadeH, H - 0.8, BLACK(), 6);
const shade = new THREE.Mesh(new THREE.CylinderGeometry(2.5, hw, shadeH, 32, 1, true), mat(f.color, 0.45, 0.4, { side: THREE.DoubleSide }));
shade.position.y = shadeH / 2; g.add(shade);
const bulb = new THREE.Mesh(new THREE.SphereGeometry(2.2, 16, 12), glowMat()); bulb.position.set(0, 2.5, 0); g.add(bulb);
pointLight(0, 0, 1.5);
break;
}
case 'fan': {
cyl(0, 0, 3, 3, H - 1, H, mat(f.color, 0.5), 16); cyl(0, 0, 0.6, 0.6, H - 6, H - 1, BLACK(), 8);
cyl(0, 0, 6, 5, H - 10, H - 6, mat('#3a3a3a', 0.4, 0.5), 24);
for (let i = 0; i < 5; i++) {
const b = box(5, hw, -2.7, 2.7, H - 8.4, H - 8, mat(f.color, 0.6));
const piv = new THREE.Group(); piv.rotation.y = i * Math.PI * 2 / 5; g.add(piv); piv.add(b);
b.castShadow = true;
}
const bowl = new THREE.Mesh(new THREE.SphereGeometry(5.5, 24, 12, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2), glowMat());
bowl.position.set(0, H - 10, 0); g.add(bowl);
pointLight(0, 0, H - 14);
break;
}
case 'chandelier': {
cyl(0, 0, 0.3, 0.3, 10, H, BLACK(), 6); cyl(0, 0, 3, 1, 4, 10, mat(f.color, 0.3, 0.7), 16);
const ring = new THREE.Mesh(new THREE.TorusGeometry(hw * 0.7, 0.5, 8, 40), mat(f.color, 0.3, 0.7)); ring.rotation.x = Math.PI / 2; ring.position.y = 6; g.add(ring);
const gm = glowMat();
for (let i = 0; i < 6; i++) {
const th = i * Math.PI / 3, x = Math.cos(th) * hw * 0.7, v = Math.sin(th) * hw * 0.7;
cyl(x, v, 1.2, 1.2, 6, 9, mat('#efe6d0', 0.6), 12);
const flame = new THREE.Mesh(new THREE.SphereGeometry(0.9, 10, 8), gm); flame.scale.y = 1.6; flame.position.set(x, 10.3, -v); g.add(flame);
}
pointLight(0, 0, 2.5);
break;
}
case 'sconce': {
box(-2.5, 2.5, hd - 0.8, hd, H * 0.3, H * 0.75, mat(f.color, 0.4, 0.5));
box(-0.4, 0.4, hd - 3, hd - 0.8, H * 0.5 - 0.4, H * 0.5 + 0.4, mat(f.color, 0.4, 0.5));
const sh = cyl(0, hd - 3.5, 2.6, 2.6, 0, H, glowMat(), 24); sh.castShadow = false;
pointLight(0, hd - 7, H * 0.6);
break;
}
case 'shoplight': {
box(-hw, hw, -hd, hd, 1, H, mat('#ffffff', 0.4));
box(-hw + 1, hw - 1, -hd + 0.5, hd - 0.5, 0.9, 1, glowMat());
const area = f.w * f.d * 0.00064516;
const L = new THREE.RectAreaLight(lcol, 0, f.w - 2, f.d - 1);
L.position.set(0, 0.8, 0); L.rotation.x = -Math.PI / 2; // face straight down, long side along the fixture
g.add(L); fix.lights.push([L, lm / (Math.PI * area) * LIGHT_K]);
break;
}
case 'floorlamp': {
cyl(0, 0, hw * 0.6, hw * 0.6, 0, 1.2, BLACK(), 24); cyl(0, 0, 0.5, 0.5, 1.2, H - 12, mat('#8a7a5a', 0.3, 0.8), 8);
const sm = glowMat(); sm.side = THREE.DoubleSide; sm.color.set(f.color);
const shade = new THREE.Mesh(new THREE.CylinderGeometry(hw * 0.7, hw, 12, 32, 1, true), sm);
shade.position.y = H - 6; g.add(shade);
pointLight(0, 0, H - 6);
break;
}
default: box(-hw, hw, -hd, hd, 0, H, C);
}
if (fix.lights.length) { fix.gi = lm ? 2.5 : 0; g.userData.fix = fix; }
return g;
}
EOF
stairs.js: straight, L and U stairs: their treads, floor openings, plan symbols, and the floor height under your feet in walk mode.
cat > /root/build/stairs.js <<'EOF'
// Stairs: straight, L and U shapes at typical code sizes (risers up to 7 3/4", 10" treads, 36" wide).
// project.stairs[] = { id, s, lvl, shape, turn, dir, x, y, w }
// lvl = the floor the stairs start on; they go up to the next floor (basement -1 to floor 1, and so on)
// dir = climbing direction at the bottom: 0 back (north), 90 right (east), 180 front (south), 270 left (west)
// turn = 'right' or 'left' for L and U stairs
// x, y = front-left corner of the whole footprint, from the building's front-left corner
// Each stair cuts an opening in the floor above it (its whole footprint).
import { uid, fmtLen, byId, levelsOf, levelBase, levelName } from './model.js';
export const STAIR_SHAPES = { straight: 'Straight', L: 'L-shaped (landing, quarter turn)', U: 'U-shaped (landing, half turn)' };
export const STAIR_DIRS = { 0: 'Goes up toward the back (north)', 90: 'Goes up toward the right (east)', 180: 'Goes up toward the front (south)', 270: 'Goes up toward the left (west)' };
export const STAIR_TURNS = { right: 'Turns right at the landing', left: 'Turns left at the landing' };
export const MAX_RISE = 7.75, TREAD = 10, GAP = 4;
const AX = { 0: [[1, 0], [0, 1]], 90: [[0, -1], [1, 0]], 180: [[-1, 0], [0, -1]], 270: [[0, 1], [-1, 0]] }; // [across, climb]
export function newStair(s, lvl, x, y, opts = {}) {
return { id: uid(), s: s.id, lvl, shape: opts.shape || 'straight', turn: opts.turn || 'right', dir: +(opts.dir ?? 0), x, y, w: opts.w || 36 };
}
export const stairOk = (s, st) => !!s && levelsOf(s).includes(st.lvl) && levelsOf(s).includes(st.lvl + 1);
// Steps (treads and landing) as rectangles in building coordinates, with their top height above the
// starting floor, plus the walking line and the flights (for handrails).
export function stairLayout(st, s) {
const rise = levelBase(s, st.lvl + 1) - levelBase(s, st.lvl);
const n = Math.max(2, Math.ceil(rise / MAX_RISE - 1e-6)), r = rise / n, T = n - 1, W = st.w || 36;
const loc = []; // [u0, v0, u1, v1, k, landing]
const flights = []; // [startU, startV, du, dv, count, k0] walking-line start of each flight
if (st.shape === 'L' && T >= 3) {
const t1 = Math.floor((T - 1) / 2), t2 = T - 1 - t1, vL = t1 * TREAD;
for (let i = 0; i < t1; i++) loc.push([0, i * TREAD, W, (i + 1) * TREAD, i + 1]);
loc.push([0, vL, W, vL + W, t1 + 1, true]);
for (let j = 0; j < t2; j++) loc.push([W + j * TREAD, vL, W + (j + 1) * TREAD, vL + W, t1 + 2 + j]);
flights.push([W / 2, 0, 0, 1, t1, 1], [W, vL + W / 2, 1, 0, t2, t1 + 2]);
} else if (st.shape === 'U' && T >= 3) {
const t1 = Math.ceil((T - 1) / 2), t2 = T - 1 - t1, vL = t1 * TREAD, u2 = W + GAP;
for (let i = 0; i < t1; i++) loc.push([0, i * TREAD, W, (i + 1) * TREAD, i + 1]);
loc.push([0, vL, 2 * W + GAP, vL + W, t1 + 1, true]);
for (let j = 0; j < t2; j++) loc.push([u2, vL - (j + 1) * TREAD, u2 + W, vL - j * TREAD, t1 + 2 + j]);
flights.push([W / 2, 0, 0, 1, t1, 1], [u2 + W / 2, vL, 0, -1, t2, t1 + 2]);
} else {
for (let i = 0; i < T; i++) loc.push([0, i * TREAD, W, (i + 1) * TREAD, i + 1]);
flights.push([W / 2, 0, 0, 1, T, 1]);
}
const left = st.turn === 'left' && st.shape !== 'straight';
const [ua, va] = AX[st.dir] || AX[0];
const raw = (u, v) => { if (left) u = -u; return [ua[0] * u + va[0] * v, ua[1] * u + va[1] * v]; };
const pts = loc.flatMap(([u0, v0, u1, v1]) => [raw(u0, v0), raw(u1, v1)]);
const mx = Math.min(...pts.map(q => q[0])), my = Math.min(...pts.map(q => q[1]));
const B = (u, v) => { const [x, y] = raw(u, v); return [x - mx + st.x, y - my + st.y]; };
const steps = loc.map(([u0, v0, u1, v1, k, landing]) => {
const a = B(u0, v0), b = B(u1, v1);
return { x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]), z: k * r, k, landing: !!landing };
});
const box = { x0: Math.min(...steps.map(t => t.x0)), y0: Math.min(...steps.map(t => t.y0)), x1: Math.max(...steps.map(t => t.x1)), y1: Math.max(...steps.map(t => t.y1)) };
// flights in building coordinates: walking line from the first nosing to the last, and the across direction
const fl = flights.map(([u, v, du, dv, cnt, k0]) => {
const len = Math.max(0, cnt - 1) * TREAD, a = B(u, v), b = B(u + du * len, v + dv * len), e = B(u + du, v + dv);
const dir = [e[0] - a[0], e[1] - a[1]];
return { a, b, dir, across: [-dir[1], dir[0]], cnt, k0, za: k0 * r, zb: (k0 + Math.max(0, cnt - 1)) * r, W };
});
// floor openings: one rectangle per flight (with the landing), or the whole box for a U stair
const holes = [];
if (st.shape === 'L' && T >= 3) {
const t1 = Math.floor((T - 1) / 2);
const g1 = steps.slice(0, t1 + 1), g2 = steps.slice(t1 + 1);
for (const g of [g1, g2]) if (g.length) holes.push({ x0: Math.min(...g.map(t => t.x0)), y0: Math.min(...g.map(t => t.y0)), x1: Math.max(...g.map(t => t.x1)), y1: Math.max(...g.map(t => t.y1)) });
} else holes.push(box);
// walking line for the arrow: start of the first flight, through the landing, to the top
const path = [B(W / 2, 0)];
if (st.shape === 'L' && T >= 3) { const vL = Math.floor((T - 1) / 2) * TREAD; path.push(B(W / 2, vL + W / 2), B(W + (T - 1 - Math.floor((T - 1) / 2)) * TREAD, vL + W / 2)); }
else if (st.shape === 'U' && T >= 3) { const t1 = Math.ceil((T - 1) / 2), vL = t1 * TREAD; path.push(B(W / 2, vL + W / 2), B(W + GAP + W / 2, vL + W / 2), B(W + GAP + W / 2, vL - (T - 1 - t1) * TREAD)); }
else path.push(B(W / 2, T * TREAD));
return { steps, box, holes, flights: fl, path, rise, n, r, T, W, run: T * TREAD };
}
export function stairText(st, s) {
const L = stairLayout(st, s);
return `${STAIR_SHAPES[st.shape] || 'Straight'} stairs from ${levelName(st.lvl)} up to ${levelName(st.lvl + 1)}: ${L.n} risers of ${fmtLen(L.r)}, ` +
`${L.T} treads 10" deep, ${fmtLen(L.W)} wide. Takes up ${fmtLen(L.box.x1 - L.box.x0)} by ${fmtLen(L.box.y1 - L.box.y0)} of floor, and cuts the same opening in the floor above.`;
}
// Floor openings (building coordinates) in the deck of level `lvl` of building s.
export function floorHoles(p, s, lvl) {
const out = [];
for (const st of p.stairs || []) if (st.s === s.id && st.lvl + 1 === lvl && stairOk(s, st)) out.push(...stairLayout(st, s).holes);
return out;
}
// ------------------------------------------------------------ plan symbols
const n2 = (v) => v.toFixed(2);
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
function arrow(pts, u, col) {
let s = `<polyline points="${pts.map(q => `${n2(q[0])},${n2(-q[1])}`).join(' ')}" fill="none" stroke="${col}" stroke-width="${n2(1.2 * u)}"/>`;
const a = pts.at(-2), b = pts.at(-1), L = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1, d = [(b[0] - a[0]) / L, (b[1] - a[1]) / L], h = 8;
s += `<polygon points="${n2(b[0])},${n2(-b[1])} ${n2(b[0] - d[0] * h - d[1] * h / 2)},${n2(-(b[1] - d[1] * h + d[0] * h / 2))} ${n2(b[0] - d[0] * h + d[1] * h / 2)},${n2(-(b[1] - d[1] * h - d[0] * h / 2))}" fill="${col}"/>`;
return s;
}
// view = level being drawn: treads on the starting floor, the opening (dashed, "DN") on the floor above.
export function stairSvg(st, s, view, u, sel, print) {
if (!stairOk(s, st) || (view !== st.lvl && view !== st.lvl + 1)) return '';
const L = stairLayout(st, s), X = s.x, Y = s.y, col = sel ? '#d97a1a' : '#222';
const R = (t, a) => `<rect x="${n2(X + t.x0)}" y="${n2(-(Y + t.y1))}" width="${n2(t.x1 - t.x0)}" height="${n2(t.y1 - t.y0)}" ${a}/>`;
const path = L.path.map(([x, y]) => [X + x, Y + y]);
let out = '';
if (view === st.lvl) {
for (const t of L.steps) out += R(t, `fill="${t.landing ? (print ? '#fff' : '#ece7dc') : '#fff'}" stroke="${col}" stroke-width="${n2((sel ? 1.8 : 0.8) * u)}"`);
out += arrow(path, u, col);
const [sx, sy] = path[0];
out += `<text x="${n2(sx)}" y="${n2(-(sy + 7))}" font-size="${n2(9 * u)}" text-anchor="middle" dominant-baseline="middle" fill="${col}" font-weight="700">UP</text>`;
} else {
for (const h of L.holes) out += R(h, `fill="${print ? 'none' : 'rgba(150,150,150,.25)'}" stroke="${col}" stroke-width="${n2((sel ? 1.8 : 1) * u)}" stroke-dasharray="${n2(5 * u)} ${n2(3 * u)}"`);
out += arrow([...path].reverse(), u, col);
const [ex, ey] = path.at(-1);
out += `<text x="${n2(ex)}" y="${n2(-ey)}" font-size="${n2(9 * u)}" text-anchor="middle" dominant-baseline="middle" fill="${col}" font-weight="700" dy="${n2(-9 * u)}">DN</text>`;
}
// from the floor above only the opening's edge picks it, so stairs underneath stay clickable
out += R(L.box, view === st.lvl ? `fill="transparent" data-kind="stair" data-id="${st.id}" style="cursor:move"`
: `fill="none" stroke="transparent" stroke-width="${n2(8 * u)}" pointer-events="stroke" data-kind="stair" data-id="${st.id}" style="cursor:move"`);
return out;
}
// ------------------------------------------------------------ walk mode
// Height of the floor under the walker's feet at plan point x, y: a stair tread within reach,
// else the highest floor of the building at or just above the feet, else the ground.
export function walkFloor(p, x, y, feet, ground) {
let best = null;
for (const st of p.stairs || []) {
const s = byId(p.structures, st.s);
if (!stairOk(s, st)) continue;
const L = stairLayout(st, s), base = levelBase(s, st.lvl), lx = x - s.x, ly = y - s.y;
for (const t of L.steps) {
if (lx < t.x0 || lx > t.x1 || ly < t.y0 || ly > t.y1) continue;
const z = base + t.z;
if (Math.abs(z - feet) <= L.r * 1.6 + 1 && (best == null || Math.abs(z - feet) < Math.abs(best - feet))) best = z;
}
}
if (best != null) return best;
for (const s of p.structures) {
if (x < s.x || x > s.x + s.w || y < s.y || y > s.y + s.d) continue;
const zs = levelsOf(s).map(l => levelBase(s, l));
const ok = zs.filter(z => z <= feet + MAX_RISE * 1.5);
return ok.length ? Math.max(...ok) : levelBase(s, levelsOf(s).includes(0) ? 0 : levelsOf(s)[0]);
}
return ground;
}
EOF
rooms.js: finds rooms from the walls, and cuts openings out of floors and ceilings.
cat > /root/build/rooms.js <<'EOF'
// Finding rooms from the walls, and cutting rectangles out of rectangles (floor openings, vaulted ceilings).
import { uid, sqft } from './model.js';
// Rectangle r = {x0, y0, x1, y1} less the holes, as a few merged rectangles.
export function rectsMinus(r, holes) {
const hs = holes.map(h => ({ x0: Math.max(r.x0, h.x0), y0: Math.max(r.y0, h.y0), x1: Math.min(r.x1, h.x1), y1: Math.min(r.y1, h.y1) }))
.filter(h => h.x1 > h.x0 + 0.01 && h.y1 > h.y0 + 0.01);
if (!hs.length) return [r];
const cuts = (k0, k1) => [...new Set([r[k0], r[k1], ...hs.flatMap(h => [h[k0], h[k1]])])].sort((a, b) => a - b);
const xs = cuts('x0', 'x1'), ys = cuts('y0', 'y1'), out = [];
let open = new Map();
for (let j = 0; j < ys.length - 1; j++) {
const y0 = ys[j], y1 = ys[j + 1], ym = (y0 + y1) / 2, spans = [];
let cur = null;
for (let i = 0; i < xs.length - 1; i++) {
const xm = (xs[i] + xs[i + 1]) / 2;
if (hs.some(h => xm > h.x0 && xm < h.x1 && ym > h.y0 && ym < h.y1)) { cur = null; continue; }
if (cur) cur.x1 = xs[i + 1]; else { cur = { x0: xs[i], x1: xs[i + 1] }; spans.push(cur); }
}
const next = new Map();
for (const sp of spans) {
const k = sp.x0 + '|' + sp.x1, o = open.get(k);
if (o) { o.y1 = y1; next.set(k, o); } else { const nr = { x0: sp.x0, x1: sp.x1, y0, y1 }; out.push(nr); next.set(k, nr); }
}
open = next;
}
return out;
}
const C = 3; // grid cell, inches
// Spaces enclosed by the outside walls and the inside walls of building s on level lvl.
// Returns [{ rects: [{x, y, w, d}] measured to wall centre lines like hand-drawn rooms, sqft, net }].
export function detectRooms(p, s, lvl) {
const nx = Math.ceil(s.w / C), ny = Math.ceil(s.d / C), g = new Uint8Array(nx * ny);
const walls = p.walls.filter(w => w.s === s.id && w.lvl === lvl);
for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
const x = (i + 0.5) * C, y = (j + 0.5) * C;
if (x < 6 || y < 6 || x > s.w - 6 || y > s.d - 6) { g[j * nx + i] = 1; continue; }
for (const w of walls) {
const dx = w.b[0] - w.a[0], dy = w.b[1] - w.a[1], L = Math.hypot(dx, dy) || 1;
const t = ((x - w.a[0]) * dx + (y - w.a[1]) * dy) / L; // along, inches
if (t < -4 || t > L + 4) continue; // ends stretched 4" to close small gaps
if (Math.abs((x - w.a[0]) * dy - (y - w.a[1]) * dx) / L <= Math.max(w.t / 2, 2.25) + 1) { g[j * nx + i] = 1; break; }
}
}
// flood fill the open cells
const lab = new Int32Array(nx * ny).fill(-1), regions = [];
for (let k = 0; k < g.length; k++) {
if (g[k] || lab[k] >= 0) continue;
const id = regions.length, cells = [k], st = [k];
lab[k] = id;
while (st.length) {
const c = st.pop(), i = c % nx, j = (c / nx) | 0;
for (const [a, b] of [[i + 1, j], [i - 1, j], [i, j + 1], [i, j - 1]]) {
if (a < 0 || b < 0 || a >= nx || b >= ny) continue;
const q = b * nx + a;
if (!g[q] && lab[q] < 0) { lab[q] = id; cells.push(q); st.push(q); }
}
}
regions.push(cells);
}
// wall lines a room edge snaps to: the outside of the building, or an inside wall's centre line
const xLines = [0, s.w, ...walls.filter(w => Math.abs(w.a[0] - w.b[0]) < 1).map(w => w.a[0])];
const yLines = [0, s.d, ...walls.filter(w => Math.abs(w.a[1] - w.b[1]) < 1).map(w => w.a[1])];
const snap = (v, lines) => { let b = null; for (const l of lines) if (Math.abs(l - v) <= 10 && (b == null || Math.abs(l - v) < Math.abs(b - v))) b = l; return b ?? Math.round(v); };
const out = [];
for (const cells of regions) {
if (cells.length * C * C < 9 * 144) continue; // slivers under 9 sq ft
const mask = new Set(cells), rects = [];
// split L and T shaped spaces into rectangles, biggest first (largest rectangle in a histogram)
for (let guard = 0; guard < 4 && mask.size * C * C >= 9 * 144; guard++) {
const is = [...mask].map(c => c % nx), js = [...mask].map(c => (c / nx) | 0);
const i0 = Math.min(...is), i1 = Math.max(...is), j0 = Math.min(...js), j1 = Math.max(...js);
const H = new Array(i1 - i0 + 2).fill(0);
let best = null;
for (let j = j0; j <= j1; j++) {
for (let i = i0; i <= i1; i++) H[i - i0] = mask.has(j * nx + i) ? H[i - i0] + 1 : 0;
const stack = [];
for (let k = 0; k <= i1 - i0 + 1; k++) {
const h = k <= i1 - i0 ? H[k] : 0;
let start = k;
while (stack.length && stack.at(-1)[1] >= h) {
const [s0, sh] = stack.pop(), area = sh * (k - s0);
if (!best || area > best.area) best = { area, i0: i0 + s0, i1: i0 + k - 1, j1: j, j0: j - sh + 1 };
start = s0;
}
stack.push([start, h]);
}
}
if (!best || best.area * C * C < 9 * 144) break;
for (let j = best.j0; j <= best.j1; j++) for (let i = best.i0; i <= best.i1; i++) mask.delete(j * nx + i);
const x0 = snap(best.i0 * C, xLines), x1 = snap((best.i1 + 1) * C, xLines), y0 = snap(best.j0 * C, yLines), y1 = snap((best.j1 + 1) * C, yLines);
if (x1 - x0 >= 24 && y1 - y0 >= 24) rects.push({ x: x0, y: y0, w: x1 - x0, d: y1 - y0 });
}
if (rects.length) out.push({ rects, sqft: rects.reduce((a, r) => a + sqft(r.w * r.d), 0), net: sqft(cells.length * C * C) });
}
return out.sort((a, b) => b.sqft - a.sqft);
}
// Updates the rooms of building s on level lvl from the walls: a space keeps the name and finishes of
// the room already drawn in it, new spaces become "Room N". Returns a plain-language summary.
export function applyDetectedRooms(p, s, lvl) {
const found = detectRooms(p, s, lvl), old = p.rooms.filter(r => r.s === s.id && r.lvl === lvl), used = new Set(), keep = [];
const centreIn = (r, q) => r.x + r.w / 2 > q.x && r.x + r.w / 2 < q.x + q.w && r.y + r.d / 2 > q.y && r.y + r.d / 2 < q.y + q.d;
let n = old.length;
for (const sp of found) sp.rects.forEach((q, i) => {
let r = old.find(r => !used.has(r) && centreIn(r, q));
if (r) { used.add(r); Object.assign(r, q); }
else {
const name = s.kind === 'garage' && found.length === 1 && !old.length ? s.name : `Room ${++n}${sp.rects.length > 1 ? ` part ${i + 1}` : ''}`;
r = { id: uid(), s: s.id, lvl, name, ...q, floor: s.kind === 'garage' || lvl === -1 ? 'concrete' : 'oak' };
p.rooms.push(r);
}
keep.push(r);
});
// drop old rooms sitting inside a space another room now covers (duplicates); keep the rest
const dropped = old.filter(r => !used.has(r) && keep.some(k => centreIn(r, k)));
p.rooms = p.rooms.filter(r => !dropped.includes(r));
const total = keep.reduce((a, r) => a + sqft(r.w * r.d), 0);
const list = keep.map(r => `${r.name} ${Math.round(sqft(r.w * r.d))}`).join(', ');
return { count: keep.length, total, text: keep.length ? `Found ${keep.length} room${keep.length > 1 ? 's' : ''} in ${s.name}: ${list} square feet. Total ${Math.round(total).toLocaleString()} square feet.` : `No enclosed rooms found in ${s.name}.` };
}
EOF
siteitems.js: trees, well, septic, utility trenches, slope arrows and the well-to-septic distance check.
cat > /root/build/siteitems.js <<'EOF'
// Things on the lot: trees, a well, a septic tank and drain field, and utility trenches, plus the
// slope arrows on the site plan. Items live in project.site.items:
// tree { id, type, kind, size, h, spread, x, y } (x, y = trunk, from the front-left lot corner)
// well { id, type, x, y }
// septic { id, type, x, y, w, d } (tank, centred on x, y)
// drainfield { id, type, x, y, w, d } (centred on x, y)
// trench { id, type, util, depth, pts: [[x, y], ...] }
import { FT, uid, fmtLen, polyLen, designAt } from './model.js';
export const TREES = {
oak: { name: 'Oak', shape: 'round', color: '#4c6b2c', h: [12, 35, 60], spread: [8, 30, 55] },
maple: { name: 'Maple', shape: 'round', color: '#5a7a2c', h: [12, 30, 50], spread: [8, 25, 40] },
birch: { name: 'Birch', shape: 'oval', color: '#7a9a3c', h: [12, 25, 40], spread: [6, 15, 25], bark: '#e8e4dc' },
apple: { name: 'Apple (fruit)', shape: 'round', color: '#557a30', h: [6, 12, 20], spread: [6, 12, 20] },
dogwood: { name: 'Dogwood (flowering)', shape: 'round', color: '#6d8a3a', h: [6, 15, 25], spread: [6, 15, 25], bloom: '#f3e9ef' },
pine: { name: 'Pine', shape: 'cone', color: '#2f5230', h: [8, 40, 70], spread: [5, 20, 30] },
spruce: { name: 'Spruce', shape: 'cone', color: '#284a36', h: [8, 30, 60], spread: [5, 15, 25] },
arborvitae: { name: 'Arborvitae (privacy)', shape: 'narrow', color: '#35592e', h: [5, 12, 20], spread: [2, 4, 6] },
};
export const TREE_SIZES = { small: 'Small (new planting)', medium: 'Medium (about 10 years)', large: 'Large (mature)' };
const SIZE_I = { small: 0, medium: 1, large: 2 };
export const UTILITIES = {
electric: { name: 'Electric', color: '#d02a20', depth: 24 },
water: { name: 'Water line', color: '#1f5fd1', depth: 48 },
sewer: { name: 'Sewer or septic line', color: '#2a8a2a', depth: 36 },
gas: { name: 'Gas', color: '#c9a400', depth: 18 },
telecom: { name: 'Internet or phone', color: '#e07a00', depth: 18 },
};
// Common rules of thumb (check the county): well 50' from a septic tank and 100' from a drain field.
export const WELL_TO_TANK = 50 * FT, WELL_TO_FIELD = 100 * FT;
export const SITE_ITEM_TYPES = { tree: 'Tree', well: 'Well', septic: 'Septic tank', drainfield: 'Septic drain field', trench: 'Utility trench' };
export function newSiteItem(type, x, y, opt = {}) {
const it = { id: uid(), type, x, y };
if (type === 'tree') {
const t = TREES[opt.kind] ? opt.kind : 'oak', i = SIZE_I[opt.size] ?? 1;
Object.assign(it, { kind: t, size: opt.size || 'medium', h: TREES[t].h[i] * FT, spread: TREES[t].spread[i] * FT });
}
if (type === 'septic') Object.assign(it, { w: 5 * FT, d: 10 * FT });
if (type === 'drainfield') Object.assign(it, { w: 30 * FT, d: 60 * FT });
if (type === 'trench') {
const u = UTILITIES[opt.util] ? opt.util : 'electric';
delete it.x; delete it.y;
Object.assign(it, { util: u, depth: UTILITIES[u].depth, pts: opt.pts || [[x, y], [x + 20 * FT, y]] });
}
return it;
}
export const itemName = (it) => it.type === 'tree' ? `${TREES[it.kind]?.name || 'Tree'} tree` : it.type === 'trench' ? `${UTILITIES[it.util]?.name || 'Utility'} trench` : SITE_ITEM_TYPES[it.type];
// Distance between two items' outlines (0 if they touch); points count as zero-size boxes.
function box(it) {
const hw = (it.w || 0) / 2, hd = (it.d || 0) / 2;
return { x0: it.x - hw, x1: it.x + hw, y0: it.y - hd, y1: it.y + hd };
}
const gap = (a, b) => { const A = box(a), B = box(b); return Math.hypot(Math.max(0, A.x0 - B.x1, B.x0 - A.x1), Math.max(0, A.y0 - B.y1, B.y0 - A.y1)); };
// Plain-language check of the well against the septic tank and drain field.
export function siteChecks(p) {
const I = p.site.items || [], out = [];
for (const w of I.filter(i => i.type === 'well')) {
for (const t of I.filter(i => i.type === 'septic' || i.type === 'drainfield')) {
const need = t.type === 'septic' ? WELL_TO_TANK : WELL_TO_FIELD, d = gap(w, t);
out.push({ ok: d >= need, text: `Well to ${t.type === 'septic' ? 'septic tank' : 'drain field'}: ${fmtLen(d)}${d >= need ? ' (OK, ' : ' (TOO CLOSE, '}${fmtLen(need)} is a common minimum).` });
}
for (const s of p.structures) {
const d = gap(w, { x: s.x + s.w / 2, y: s.y + s.d / 2, w: s.w, d: s.d });
if (d < 10 * FT) out.push({ ok: false, text: `Well is ${fmtLen(d)} from ${s.name}; 10' or more is usual for drilling access.` });
}
}
for (const t of I.filter(i => i.type === 'drainfield')) for (const s of p.structures) {
const d = gap(t, { x: s.x + s.w / 2, y: s.y + s.d / 2, w: s.w, d: s.d });
if (d < 10 * FT) out.push({ ok: false, text: `Drain field is ${fmtLen(d)} from ${s.name}; 10' or more is a common minimum.` });
}
return out;
}
export function itemText(it) {
if (it.type === 'trench') return `${itemName(it)}, ${fmtLen(polyLen(it.pts))} long, ${fmtLen(it.depth)} deep, ${it.pts.length} points.`;
if (it.type === 'tree') return `${itemName(it)}, ${fmtLen(it.h)} tall, ${fmtLen(it.spread)} across, trunk ${fmtLen(it.x)} from the left lot line and ${fmtLen(it.y)} from the street.`;
const size = it.w ? `, ${fmtLen(it.w)} by ${fmtLen(it.d)}` : '';
return `${itemName(it)}${size}, centre ${fmtLen(it.x)} from the left lot line and ${fmtLen(it.y)} from the street.`;
}
// ------------------------------------------------------------ site plan drawing
const n2 = (v) => v.toFixed(2);
const esc = (t) => String(t).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const T = (x, y, s, size, a = '') => `<text x="${n2(x)}" y="${n2(-y)}" font-size="${n2(size)}" text-anchor="middle" dominant-baseline="middle" pointer-events="none" ${a}>${esc(s)}</text>`;
// Downhill arrows every 20 feet on the finished ground, labelled with the slope in percent.
export function slopeArrows(p, u) {
let s = '';
const step = 20 * FT, h = 2 * FT;
for (let y = step / 2; y < p.site.d; y += step) for (let x = step / 2; x < p.site.w; x += step) {
if (p.structures.some(st => x > st.x - 4 * FT && x < st.x + st.w + 4 * FT && y > st.y - 4 * FT && y < st.y + st.d + 4 * FT)) continue;
const gx = (designAt(p, x + h, y) - designAt(p, x - h, y)) / (2 * h), gy = (designAt(p, x, y + h) - designAt(p, x, y - h)) / (2 * h);
const g = Math.hypot(gx, gy);
if (g < 0.004) continue;
const dx = -gx / g, dy = -gy / g, L = 7 * FT, a = [x - dx * L / 2, y - dy * L / 2], b = [x + dx * L / 2, y + dy * L / 2], k = 2.2 * FT;
s += `<line x1="${n2(a[0])}" y1="${n2(-a[1])}" x2="${n2(b[0])}" y2="${n2(-b[1])}" stroke="#2c6aa0" stroke-width="${n2(1.1 * u)}" pointer-events="none"/>`;
s += `<polygon points="${n2(b[0])},${n2(-b[1])} ${n2(b[0] - dx * k - dy * k / 2)},${n2(-(b[1] - dy * k + dx * k / 2))} ${n2(b[0] - dx * k + dy * k / 2)},${n2(-(b[1] - dy * k - dx * k / 2))}" fill="#2c6aa0" pointer-events="none"/>`;
s += T(x - dy * 2.2 * FT, y + dx * 2.2 * FT, `${(g * 100).toFixed(g < 0.1 ? 1 : 0)}%`, 7.5 * u, 'fill="#2c6aa0"');
}
return s;
}
// layer 'under' = trenches, septic, well (drawn below the buildings); 'over' = trees.
export function siteItemsSvg(p, u, sel, layer, print) {
let s = '';
for (const it of p.site.items || []) {
const isSel = sel === it.id, hl = isSel ? '#d97a1a' : null, hit = `data-kind="site" data-id="${it.id}" style="cursor:move"`;
if (layer === 'under' && it.type === 'trench') {
const U = UTILITIES[it.util] || UTILITIES.electric, pts = it.pts.map(q => `${n2(q[0])},${n2(-q[1])}`).join(' ');
s += `<polyline points="${pts}" fill="none" stroke="${hl || U.color}" stroke-width="${n2((isSel ? 3 : 2) * u)}" stroke-dasharray="${n2(8 * u)} ${n2(4 * u)}"/>`;
s += `<polyline points="${pts}" fill="none" stroke="transparent" stroke-width="${n2(12 * u)}" ${hit}/>`;
let bi = 1, bl = 0;
for (let i = 1; i < it.pts.length; i++) { const l = Math.hypot(it.pts[i][0] - it.pts[i - 1][0], it.pts[i][1] - it.pts[i - 1][1]); if (l > bl) { bl = l; bi = i; } }
const a = it.pts[bi - 1], b = it.pts[bi];
s += T((a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 9 * u, `${U.name} ${fmtLen(polyLen(it.pts))}`, 8.5 * u, `fill="${U.color}" font-weight="700"`);
for (const q of it.pts) s += `<circle cx="${n2(q[0])}" cy="${n2(-q[1])}" r="${n2(2.5 * u)}" fill="${U.color}" pointer-events="none"/>`;
}
if (layer === 'under' && (it.type === 'septic' || it.type === 'drainfield')) {
const b = box(it), field = it.type === 'drainfield';
s += `<rect x="${n2(b.x0)}" y="${n2(-b.y1)}" width="${n2(it.w)}" height="${n2(it.d)}" fill="${field ? (print ? 'none' : 'rgba(80,140,60,.18)') : (print ? '#fff' : '#d6d2c8')}" stroke="${hl || '#5a4a2a'}" stroke-width="${n2((isSel ? 2.5 : 1.2) * u)}" ${field ? `stroke-dasharray="${n2(6 * u)} ${n2(3 * u)}"` : ''} ${hit}/>`;
if (field) for (let x = b.x0 + 3 * FT; x < b.x1 - FT; x += 6 * FT) s += `<line x1="${n2(x)}" y1="${n2(-b.y0 - 2 * FT)}" x2="${n2(x)}" y2="${n2(-b.y1 + 2 * FT)}" stroke="#5a4a2a" stroke-width="${n2(0.7 * u)}" stroke-dasharray="${n2(3 * u)} ${n2(3 * u)}" pointer-events="none"/>`;
s += T(it.x, it.y, field ? 'Drain field' : 'Septic', 9 * u, 'fill="#3a2e18" font-weight="700"');
}
if (layer === 'under' && it.type === 'well') {
s += `<circle cx="${n2(it.x)}" cy="${n2(-it.y)}" r="${n2(2.5 * FT)}" fill="${print ? '#fff' : '#cfe0f0'}" stroke="${hl || '#1f4f8a'}" stroke-width="${n2((isSel ? 2.5 : 1.4) * u)}" ${hit}/>`;
s += T(it.x, it.y, 'W', 10 * u, 'fill="#1f4f8a" font-weight="700"') + T(it.x, it.y - 4.5 * FT, 'Well', 8.5 * u, 'fill="#1f4f8a"');
}
if (layer === 'over' && it.type === 'tree') {
const d = TREES[it.kind] || TREES.oak, r = it.spread / 2;
if (d.shape === 'cone' || d.shape === 'narrow') {
const pts = [];
for (let i = 0; i < 24; i++) { const a = i / 24 * Math.PI * 2, rr = i % 2 ? r * 0.72 : r; pts.push(`${n2(it.x + Math.cos(a) * rr)},${n2(-(it.y + Math.sin(a) * rr))}`); }
s += `<polygon points="${pts.join(' ')}" fill="${print ? 'none' : 'rgba(40,90,50,.35)'}" stroke="${hl || '#1f4a2a'}" stroke-width="${n2((isSel ? 2.5 : 1) * u)}" ${hit}/>`;
} else {
let path = '';
for (let i = 0; i <= 16; i++) { const a = i / 16 * Math.PI * 2, rr = r * (0.9 + 0.1 * Math.cos(i * 2.7)); path += `${i ? 'L' : 'M'}${n2(it.x + Math.cos(a) * rr)},${n2(-(it.y + Math.sin(a) * rr))}`; }
s += `<path d="${path}Z" fill="${print ? 'none' : 'rgba(70,120,40,.3)'}" stroke="${hl || '#2d5a1e'}" stroke-width="${n2((isSel ? 2.5 : 1) * u)}" ${hit}/>`;
}
s += `<circle cx="${n2(it.x)}" cy="${n2(-it.y)}" r="${n2(Math.max(2, r * 0.06))}" fill="#5b3d22" pointer-events="none"/>`;
s += T(it.x, it.y - Math.max(r * 0.35, 3 * FT), d.name.split(' ')[0], 8 * u, 'fill="#1f3a14"');
}
}
return s;
}
// Nearest wall point on a building, for "trench from the street to the house".
export function trenchToBuilding(p, s, util, fromX) {
const x = Math.min(Math.max(fromX ?? s.x + s.w / 2, s.x + 12), s.x + s.w - 12);
return newSiteItem('trench', 0, 0, { util, pts: [[x, 0], [x, s.y]] });
}
EOF
finishes.js: wallpaper, inside wainscot, backsplash and ceiling types, and their areas.
cat > /root/build/finishes.js <<'EOF'
// Inside finishes per room: wallpaper, inside wainscot, tile backsplash and the ceiling type.
// Room fields: wallpaper, wpColor, wainscot, wainH, wainColor, backsplash, bsColor, ceiling, ceilColor.
// Finishes go on the inner faces of the room's four edges, around the doors and windows in them.
import { sqft, openingWall, levelsOf, WALLPAPER, BACKSPLASH, WAINSCOT_IN, CEILINGS } from './model.js';
import { FURN } from './furniture.js';
const COUNTERS = new Set(['base24', 'base36', 'counter96', 'sink36', 'range', 'dw', 'vanity30', 'vanity60']);
export const BACKSPLASH_H = 18;
// Inner faces of a room: 6" in from an outside wall, half an inside wall (2 1/4") otherwise. Absolute plan coordinates.
export function roomInner(s, r) {
const e = (out) => (out ? 6 : 2.25);
return { x0: s.x + r.x + e(r.x <= 0), y0: s.y + r.y + e(r.y <= 0), x1: s.x + r.x + r.w - e(r.x + r.w >= s.w), y1: s.y + r.y + r.d - e(r.y + r.d >= s.d) };
}
// The four inner faces, counter-clockwise, with the wall side on the right (like outside walls),
// each with the doors and windows that pass through it: ops = [{ t0, t1, z0, z1 }] above the floor.
export function roomEdges(p, s, r) {
const b = roomInner(s, r);
const E = [
{ side: 'S', a: [b.x0, b.y0], b: [b.x1, b.y0] }, { side: 'E', a: [b.x1, b.y0], b: [b.x1, b.y1] },
{ side: 'N', a: [b.x1, b.y1], b: [b.x0, b.y1] }, { side: 'W', a: [b.x0, b.y1], b: [b.x0, b.y0] },
];
for (const e of E) {
const dx = e.b[0] - e.a[0], dy = e.b[1] - e.a[1];
e.len = Math.hypot(dx, dy); e.d = [dx / (e.len || 1), dy / (e.len || 1)]; e.ops = [];
const horiz = Math.abs(dy) < 0.01;
for (const o of p.openings) {
if (o.s !== s.id || o.lvl !== r.lvl) continue;
const w = openingWall(p, o);
if (!w) continue;
const wh = Math.abs(w.a[1] - w.b[1]) < 0.5, wv = Math.abs(w.a[0] - w.b[0]) < 0.5;
// the opening's wall must run along this edge, within a wall thickness of it
if (horiz ? !(wh && Math.abs(w.a[1] - e.a[1]) <= 8) : !(wv && Math.abs(w.a[0] - e.a[0]) <= 8)) continue;
const wd = [(w.b[0] - w.a[0]) / w.len, (w.b[1] - w.a[1]) / w.len];
const P0 = [w.a[0] + wd[0] * o.off, w.a[1] + wd[1] * o.off], P1 = [w.a[0] + wd[0] * (o.off + o.w), w.a[1] + wd[1] * (o.off + o.w)];
const t0 = (P0[0] - e.a[0]) * e.d[0] + (P0[1] - e.a[1]) * e.d[1], t1 = (P1[0] - e.a[0]) * e.d[0] + (P1[1] - e.a[1]) * e.d[1];
const lo = Math.max(0, Math.min(t0, t1)), hi = Math.min(e.len, Math.max(t0, t1));
if (hi > lo + 1) e.ops.push({ t0: lo, t1: hi, z0: o.sill, z1: o.sill + o.h });
}
e.ops.sort((a, b2) => a.t0 - b2.t0);
}
return E;
}
// Pieces of a band [za, zb] over [ta, tb] of an edge, leaving holes for its openings: [[t0, t1, z0, z1]].
export function bandPieces(e, ta, tb, za, zb) {
const out = [];
let t = ta;
for (const o of e.ops) {
if (o.t1 <= ta || o.t0 >= tb) continue;
const a = Math.max(ta, o.t0), b = Math.min(tb, o.t1);
if (a > t) out.push([t, a, za, zb]);
if (o.z0 > za) out.push([a, b, za, Math.min(zb, o.z0)]);
if (o.z1 < zb) out.push([a, b, Math.max(za, o.z1), zb]);
t = Math.max(t, b);
}
if (t < tb) out.push([t, tb, za, zb]);
return out.filter(([a, b, c, d]) => b - a > 0.2 && d - c > 0.2);
}
// Backsplash runs: along each edge, where counters, sinks, ranges or vanities back onto it.
// Returns [{ edge, t0, t1, z0 }] with z0 = counter top above the floor.
export function backsplashRuns(p, s, r, edges = roomEdges(p, s, r)) {
const out = [];
for (const f of p.furniture || []) {
if (f.s !== s.id || f.lvl !== r.lvl || !COUNTERS.has(f.type)) continue;
const a = (f.rot || 0) * Math.PI / 180, bx = s.x + f.x - Math.sin(a) * f.d / 2, by = s.y + f.y + Math.cos(a) * f.d / 2;
for (const e of edges) {
const rx = bx - e.a[0], ry = by - e.a[1], along = rx * e.d[0] + ry * e.d[1], across = Math.abs(rx * e.d[1] - ry * e.d[0]);
if (across > 4 || along < -6 || along > e.len + 6) continue;
const t0 = Math.max(0, along - f.w / 2), t1 = Math.min(e.len, along + f.w / 2), z0 = f.h + (f.z || 0);
const prev = out.find(q => q.edge === e && Math.abs(q.z0 - z0) < 3 && t0 <= q.t1 + 1 && t1 >= q.t0 - 1);
if (prev) { prev.t0 = Math.min(prev.t0, t0); prev.t1 = Math.max(prev.t1, t1); } else out.push({ edge: e, t0, t1, z0 });
break;
}
}
return out;
}
// Square feet of each finish in a building, for the materials list and the cost estimate.
export function finishAreas(p, s) {
const A = { wallpaper: 0, wainscot: 0, backsplash: 0, tray: 0, coffered: 0, vaulted: 0, wallpaperBy: {}, backsplashBy: {}, wainscotBy: {} };
for (const r of p.rooms) {
if (r.s !== s.id) continue;
const edges = roomEdges(p, s, r), wh = r.wainscot && r.wainscot !== 'none' ? r.wainH || 36 : 0;
const area = (z0, z1) => edges.reduce((a, e) => a + bandPieces(e, 0, e.len, z0, z1).reduce((b, [t0, t1, c, d]) => b + (t1 - t0) * (d - c), 0), 0) / 144;
if (wh) { const v = area(0, wh); A.wainscot += v; A.wainscotBy[r.wainscot] = (A.wainscotBy[r.wainscot] || 0) + v; }
if (r.wallpaper && r.wallpaper !== 'none') { const v = area(wh, s.wallH); A.wallpaper += v; A.wallpaperBy[r.wallpaper] = (A.wallpaperBy[r.wallpaper] || 0) + v; }
if (r.backsplash && r.backsplash !== 'none') {
const v = backsplashRuns(p, s, r, edges).reduce((a, q) => a + (q.t1 - q.t0) * BACKSPLASH_H, 0) / 144;
A.backsplash += v; A.backsplashBy[r.backsplash] = (A.backsplashBy[r.backsplash] || 0) + v;
}
const c = r.ceiling || 'flat';
if (c === 'tray') A.tray++;
if (c === 'coffered') A.coffered += sqft(r.w * r.d);
if (c === 'vaulted' && vaultOk(s, r)) A.vaulted += sqft(r.w * r.d);
}
return A;
}
export const vaultOk = (s, r) => r.lvl === Math.max(...levelsOf(s)) && r.lvl >= 0;
export function finishText(s, r) {
const L = [];
if (r.wallpaper && r.wallpaper !== 'none') L.push(`${WALLPAPER[r.wallpaper]} wallpaper`);
if (r.wainscot && r.wainscot !== 'none') L.push(`${WAINSCOT_IN[r.wainscot].toLowerCase()} wainscot`);
if (r.backsplash && r.backsplash !== 'none') L.push(`${BACKSPLASH[r.backsplash].toLowerCase()} backsplash`);
const c = r.ceiling || 'flat';
if (c !== 'flat') L.push(`${c === 'vaulted' && !vaultOk(s, r) ? 'vaulted (shown flat: not the top floor)' : CEILINGS[c].split(' (')[0].toLowerCase()} ceiling`);
return L.join(', ');
}
EOF
estimate.js: the unit prices and the rough cost estimate.
cat > /root/build/estimate.js <<'EOF'
// Rough cost estimate per building plus the site work, from unit prices the user can change.
// Prices are typical US installed costs (labor and materials) for planning only; project.prices
// holds the user's changes, keyed like PRICES.
import { FT, sqft, polyLen, materials, earthwork, levelsOf, FLOORING, SIDING, ROOFING } from './model.js';
import { finishAreas } from './finishes.js';
import { UTILITIES } from './siteitems.js';
// [group, label, unit, price]
export const PRICES = {
slab: ['Foundation', 'Slab on grade', 'sq ft of footprint', 9],
crawl: ['Foundation', 'Crawlspace', 'sq ft of footprint', 14],
basement: ['Foundation', 'Full basement (unfinished)', 'sq ft of footprint', 38],
walkout: ['Foundation', 'Walkout basement (unfinished)', 'sq ft of footprint', 44],
bsmtFinish: ['Foundation', 'Finishing a basement room', 'sq ft of room', 45],
shellHouse: ['Structure', 'House framing, sheathing, insulation, drywall, paint', 'sq ft of floor', 62],
shellGarage: ['Structure', 'Garage framing and sheathing', 'sq ft of floor', 32],
systems: ['Structure', 'Plumbing, electrical, heating and cooling (house)', 'sq ft of floor', 34],
garageElec: ['Structure', 'Garage wiring and lights', 'sq ft of floor', 5],
sid_vinyl: ['Outside', SIDING.vinyl, 'sq ft', 6], sid_fibercement: ['Outside', SIDING.fibercement, 'sq ft', 11],
sid_board: ['Outside', SIDING.board, 'sq ft', 10], sid_shake: ['Outside', SIDING.shake, 'sq ft', 13],
sid_brick: ['Outside', SIDING.brick, 'sq ft', 24], sid_stone: ['Outside', SIDING.stone, 'sq ft', 32], sid_stucco: ['Outside', SIDING.stucco, 'sq ft', 12],
roof_asphalt: ['Outside', ROOFING.asphalt, 'square (100 sq ft)', 480], roof_metal: ['Outside', ROOFING.metal, 'square (100 sq ft)', 1150],
soffit: ['Outside', 'Soffit', 'sq ft', 9], fascia: ['Outside', 'Fascia and rake trim', 'foot', 9],
gutter: ['Outside', 'Gutters', 'foot', 11], downspout: ['Outside', 'Downspouts', 'each', 140],
window: ['Openings', 'Windows', 'each', 700], slider: ['Openings', 'Sliding glass doors', 'each', 2400],
egress: ['Openings', 'Egress window cut-in with window well', 'each', 4200],
extDoor: ['Openings', 'Outside doors (double doors count as 2)', 'each', 1600], intDoor: ['Openings', 'Inside doors', 'each', 380],
garageDoor: ['Openings', 'Garage doors with opener', 'each', 2600],
fl_oak: ['Floors', FLOORING.oak, 'sq ft', 12], fl_walnut: ['Floors', FLOORING.walnut, 'sq ft', 16], fl_lvp: ['Floors', FLOORING.lvp, 'sq ft', 6],
fl_tile: ['Floors', FLOORING.tile, 'sq ft', 15], fl_tile12: ['Floors', FLOORING.tile12, 'sq ft', 11], fl_carpet: ['Floors', FLOORING.carpet, 'sq ft', 5],
fl_concrete: ['Floors', FLOORING.concrete, 'sq ft', 5],
stair_straight: ['Inside', 'Straight stairs', 'each', 3500], stair_L: ['Inside', 'L-shaped stairs', 'each', 5200], stair_U: ['Inside', 'U-shaped stairs', 'each', 6000],
wallpaper: ['Inside', 'Wallpaper, hung', 'sq ft of wall', 7], wainscotIn: ['Inside', 'Inside wainscot', 'sq ft of wall', 22],
backsplash: ['Inside', 'Tile backsplash', 'sq ft', 32], tray: ['Inside', 'Tray ceiling', 'each room', 1800],
coffered: ['Inside', 'Coffered ceiling', 'sq ft of ceiling', 28], vaulted: ['Inside', 'Vaulted ceiling (extra framing)', 'sq ft of ceiling', 18],
kitchen: ['Inside', 'Kitchen cabinets, counters and appliances (rooms named kitchen)', 'each', 32000],
bath: ['Inside', 'Bathroom fixtures and tile (rooms named bath)', 'each', 11000],
excavation: ['Site', 'Dirt work: cut, fill and basement dig', 'cubic yard', 22], haul: ['Site', 'Hauling extra dirt', 'truck load', 380],
driveway: ['Site', 'Concrete driveway', 'sq ft', 10],
tr_electric: ['Site', 'Electric trench and conduit', 'foot', 18], tr_water: ['Site', 'Water line trench', 'foot', 24],
tr_sewer: ['Site', 'Sewer or septic line trench', 'foot', 30], tr_gas: ['Site', 'Gas line trench', 'foot', 22], tr_telecom: ['Site', 'Internet or phone trench', 'foot', 12],
well: ['Site', 'Drilled well with pump', 'each', 16000], septic: ['Site', 'Septic tank', 'each', 6500], drainfield: ['Site', 'Septic drain field', 'sq ft', 6],
tree_small: ['Site', 'Tree, small (planted)', 'each', 180], tree_medium: ['Site', 'Tree, medium (planted)', 'each', 700], tree_large: ['Site', 'Tree, large (kept, protected)', 'each', 300],
permits: ['Site', 'Permits, plans and inspections', 'each project', 9000],
contingency: ['Totals', 'Contingency (surprises)', 'percent', 10],
};
export const PRICE_GROUPS = ['Foundation', 'Structure', 'Outside', 'Openings', 'Floors', 'Inside', 'Site', 'Totals'];
export const price = (p, k) => (p.prices && p.prices[k] != null && !isNaN(p.prices[k])) ? +p.prices[k] : PRICES[k][3];
export function estimate(p) {
const M = materials(p), out = { buildings: [], site: null, total: 0 };
const line = (L, key, qty, note = '') => { if (!qty) return; const pr = price(p, key); L.push({ key, label: PRICES[key][1] + note, qty, unit: PRICES[key][2], price: pr, cost: qty * pr }); };
p.structures.forEach((s, i) => {
const m = M[i], L = [], foot = sqft(s.w * s.d), above = foot * s.floors, house = s.kind === 'house';
line(L, s.foundation, foot);
line(L, house ? 'shellHouse' : 'shellGarage', above);
line(L, house ? 'systems' : 'garageElec', above);
// basement rooms with a finished floor (bare concrete counts as unfinished)
if (levelsOf(s).includes(-1)) line(L, 'bsmtFinish', p.rooms.filter(r => r.s === s.id && r.lvl === -1 && r.floor !== 'concrete').reduce((a, r) => a + sqft(r.w * r.d), 0));
line(L, 'sid_' + s.ext.mat, Math.max(0, m.sidingSqft));
if (m.wainscotSqft) line(L, 'sid_' + s.ext.wainscotMat, m.wainscotSqft, ' (wainscot)');
line(L, 'roof_' + s.roof.mat, m.roofSquares);
line(L, 'soffit', m.soffitSqft); line(L, 'fascia', m.fasciaFt); line(L, 'gutter', m.gutterFt); line(L, 'downspout', m.downspouts);
line(L, 'window', m.windows); line(L, 'slider', m.sliders); line(L, 'egress', m.egress);
let ext = 0, int = 0, gar = 0;
for (const o of p.openings) if (o.s === s.id) {
const k = o.style === 'double' ? 2 : 1;
if (o.kind === 'garage') gar++; else if (o.kind === 'door') { if (o.wall.length === 1) ext += k; else int += k; }
}
line(L, 'extDoor', ext); line(L, 'intDoor', int); line(L, 'garageDoor', gar);
for (const [f, a] of Object.entries(m.floors)) if (PRICES['fl_' + f] && !(f === 'concrete' && !house)) line(L, 'fl_' + f, a);
for (const sh of ['straight', 'L', 'U']) line(L, 'stair_' + sh, (p.stairs || []).filter(st => st.s === s.id && (PRICES['stair_' + st.shape] ? st.shape : 'straight') === sh).length);
const F = finishAreas(p, s);
line(L, 'wallpaper', F.wallpaper); line(L, 'wainscotIn', F.wainscot); line(L, 'backsplash', F.backsplash);
line(L, 'tray', F.tray); line(L, 'coffered', F.coffered); line(L, 'vaulted', F.vaulted);
if (house) {
line(L, 'kitchen', p.rooms.filter(r => r.s === s.id && /kitchen/i.test(r.name)).length);
line(L, 'bath', p.rooms.filter(r => r.s === s.id && /bath/i.test(r.name)).length);
}
const total = L.reduce((a, q) => a + q.cost, 0);
out.buildings.push({ name: s.name, lines: L, total, sqft: above, perSqft: above ? total / above : 0 });
});
// site work
const L = [], ew = earthwork(p), net = ew.cut + ew.dig - ew.fill;
line(L, 'excavation', ew.cut + ew.fill + ew.dig);
line(L, 'haul', Math.ceil(Math.max(0, net) / 12));
if (p.site.drive) {
let a = 0;
for (const g of p.structures) {
const doors = p.openings.filter(o => o.s === g.id && o.kind === 'garage' && o.wall === 'S' && o.lvl === 0);
if (doors.length) a += (Math.max(...doors.map(o => o.off + o.w)) - Math.min(...doors.map(o => o.off)) + 36) * (g.y + 3 * FT);
}
line(L, 'driveway', sqft(a));
}
const I = p.site.items || [];
for (const u of Object.keys(UTILITIES)) line(L, 'tr_' + u, I.filter(i => i.type === 'trench' && i.util === u).reduce((a, t) => a + polyLen(t.pts), 0) / 12);
line(L, 'well', I.filter(i => i.type === 'well').length);
line(L, 'septic', I.filter(i => i.type === 'septic').length);
line(L, 'drainfield', I.filter(i => i.type === 'drainfield').reduce((a, t) => a + sqft(t.w * t.d), 0));
for (const z of ['small', 'medium', 'large']) line(L, 'tree_' + z, I.filter(i => i.type === 'tree' && i.size === z).length);
if (p.structures.length) line(L, 'permits', 1);
out.site = { name: 'Site work', lines: L, total: L.reduce((a, q) => a + q.cost, 0) };
out.subtotal = out.buildings.reduce((a, b) => a + b.total, 0) + out.site.total;
out.contPct = price(p, 'contingency');
out.contingency = out.subtotal * out.contPct / 100;
out.total = out.subtotal + out.contingency;
out.sqft = out.buildings.reduce((a, b) => a + b.sqft, 0);
return out;
}
EOF
details3d.js: 3D stairs and guard rails, soffits, fascia, gutters and downspouts, room finishes and ceilings, trees and window wells.
cat > /root/build/details3d.js <<'EOF'
// 3D for the phase 3 details: stairs and their guard rails, soffits, fascia, gutters and downspouts,
// room finishes and ceilings, trees and the other site items, and egress window wells.
// H = helpers from view3d.js: { wallBox, polyMesh, M (textured material), plain (flat material), V }.
import * as THREE from 'three';
import { levelBase, extDetails, roofEdges, designAt, ridgeAxis, plateZ, windowWell, byId } from './model.js';
import { stairLayout, stairOk, TREAD } from './stairs.js';
import { roomInner, roomEdges, bandPieces, backsplashRuns, BACKSPLASH_H, vaultOk } from './finishes.js';
import { rectsMinus } from './rooms.js';
import { TREES, UTILITIES } from './siteitems.js';
const shadow = (m) => { m.castShadow = m.receiveShadow = true; return m; };
// A cylinder (rail) between two plan points with heights: [x, y, z].
function rod(A, B, r, mat, V) {
const a = V(...A), b = V(...B), len = a.distanceTo(b);
const m = new THREE.Mesh(new THREE.CylinderGeometry(r, r, len, 10), mat);
m.position.copy(a).add(b).multiplyScalar(0.5);
m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.clone().sub(a).normalize());
return shadow(m);
}
// Upright boxes (balusters) as one instanced mesh: list of [x, y, z0, z1].
function spindles(list, w, mat, V) {
if (!list.length) return null;
const im = new THREE.InstancedMesh(new THREE.BoxGeometry(w, 1, w), mat, list.length), M4 = new THREE.Matrix4();
list.forEach(([x, y, z0, z1], i) => {
M4.makeScale(1, Math.max(0.1, z1 - z0), 1).setPosition(V(x, y, (z0 + z1) / 2));
im.setMatrixAt(i, M4);
});
im.castShadow = true;
return im;
}
// ------------------------------------------------------------ stairs
// Treads and handrails of stairs starting on lvl; guard rails round the openings of stairs arriving on lvl.
export function stairs3d(p, s, lvl, G, H) {
const wood = H.M(s.floor === 'carpet' || s.floor === 'tile' || s.floor === 'tile12' ? 'oak' : s.floor || 'oak');
const white = H.plain('#f4f2ee'), railM = H.plain('#5a3d26', { roughness: 0.45 });
for (const st of p.stairs || []) {
if (st.s !== s.id || !stairOk(s, st)) continue;
const L = stairLayout(st, s);
if (st.lvl === lvl) {
const base = levelBase(s, lvl), mats = [white, white, wood, white, white, white];
for (const t of L.steps) {
const yc = s.y + (t.y0 + t.y1) / 2, hd = (t.y1 - t.y0) / 2;
const m = H.wallBox([s.x + t.x0, yc], [s.x + t.x1, yc], 0, t.x1 - t.x0, hd, hd, base, base + t.z, mats);
if (m) G.add(m);
}
// handrails 34" over the nosings on both sides, newel posts, two balusters per tread
const bal = [];
for (const f of L.flights) {
if (f.cnt < 2) continue;
for (const side of [-1, 1]) {
const off = side * (f.W / 2 - 2), P = (q) => [s.x + q[0] + f.across[0] * off, s.y + q[1] + f.across[1] * off];
const A = P(f.a), B = P(f.b);
G.add(rod([...A, base + f.za + 34], [...B, base + f.zb + 34], 1.1, railM, H.V));
for (const [q, z] of [[A, f.za], [B, f.zb]]) {
const m = H.wallBox([q[0] - 1.75, q[1]], [q[0] + 1.75, q[1]], 0, 3.5, 1.75, 1.75, base + z - L.r, base + z + 38, white);
if (m) G.add(m);
}
for (let i = 0; i < f.cnt; i++) for (const dt of [2.5, 7.5]) {
const t = i * TREAD + dt, x = A[0] + f.dir[0] * t, y = A[1] + f.dir[1] * t;
bal.push([x, y, base + (f.k0 + i) * L.r, base + (f.k0 + t / TREAD) * L.r + 33]);
}
}
}
const sp = spindles(bal, 1.25, white, H.V);
if (sp) G.add(sp);
}
if (st.lvl + 1 === lvl) guards(p, s, lvl, L, G, H, railM, white);
}
}
// 36" guard rail round a floor opening, except along walls, between the openings of one stair, and at the top step.
function guards(p, s, lvl, L, G, H, railM, white) {
const z = levelBase(s, lvl), end = L.path.at(-1), bal = [];
const walls = p.walls.filter(w => w.s === s.id && w.lvl === lvl);
// [t0, t1] of the line P + d t that lies inside rectangle o (axis-aligned)
const span = (P, d, o) => {
let lo = -1e9, hi = 1e9;
for (const [k, m0, m1] of [[0, o.x0, o.x1], [1, o.y0, o.y1]]) {
if (Math.abs(d[k]) < 1e-9) { if (P[k] <= m0 || P[k] >= m1) return null; continue; }
const a = (m0 - P[k]) / d[k], b = (m1 - P[k]) / d[k];
lo = Math.max(lo, Math.min(a, b)); hi = Math.min(hi, Math.max(a, b));
}
return hi > lo ? [lo, hi] : null;
};
for (const h of L.holes) {
const E = [[[h.x0, h.y0], [h.x1, h.y0], [0, -1]], [[h.x1, h.y0], [h.x1, h.y1], [1, 0]], [[h.x1, h.y1], [h.x0, h.y1], [0, 1]], [[h.x0, h.y1], [h.x0, h.y0], [-1, 0]]];
for (const [a, b, n] of E) {
const len = Math.hypot(b[0] - a[0], b[1] - a[1]), d = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
let segs = [[0, len]];
const cut = (lo, hi) => { segs = segs.flatMap(([u0, u1]) => hi <= u0 || lo >= u1 ? [[u0, u1]] : [[u0, lo], [hi, u1]].filter(([x, y]) => y - x > 2)); };
for (const o of L.holes) if (o !== h) { const sp = span([a[0] + n[0] * 3, a[1] + n[1] * 3], d, o); if (sp) cut(...sp); } // shared with the other opening
const te = (end[0] - a[0]) * d[0] + (end[1] - a[1]) * d[1], ac = Math.abs((end[0] - a[0]) * n[0] + (end[1] - a[1]) * n[1]);
if (ac < 3) cut(te - L.W / 2 - 1, te + L.W / 2 + 1); // the top step, where you walk off
for (const [u0, u1] of segs) {
const mx = a[0] + d[0] * (u0 + u1) / 2, my = a[1] + d[1] * (u0 + u1) / 2;
if ((n[0] && (mx < 9 || mx > s.w - 9)) || (n[1] && (my < 9 || my > s.d - 9))) continue; // against an outside wall
if (walls.some(w => !n[0] ? Math.abs(w.a[1] - w.b[1]) < 1 && Math.abs(w.a[1] - my) < 6 && Math.min(w.a[0], w.b[0]) < mx && Math.max(w.a[0], w.b[0]) > mx
: Math.abs(w.a[0] - w.b[0]) < 1 && Math.abs(w.a[0] - mx) < 6 && Math.min(w.a[1], w.b[1]) < my && Math.max(w.a[1], w.b[1]) > my)) continue;
const A = [s.x + a[0] + d[0] * u0 - n[0] * 1.5, s.y + a[1] + d[1] * u0 - n[1] * 1.5], B = [s.x + a[0] + d[0] * u1 - n[0] * 1.5, s.y + a[1] + d[1] * u1 - n[1] * 1.5];
const m = H.wallBox(A, B, 0, u1 - u0, 1.5, 1.5, z + 34, z + 36.5, railM);
if (m) G.add(m);
for (const q of [A, B]) { const pm = H.wallBox([q[0] - 1.75, q[1]], [q[0] + 1.75, q[1]], 0, 3.5, 1.75, 1.75, z, z + 38, white); if (pm) G.add(pm); }
for (let t = 4.5; t < u1 - u0 - 2; t += 4.5) bal.push([A[0] + d[0] * t, A[1] + d[1] * t, z, z + 34]);
}
}
}
const sp = spindles(bal, 1.25, white, H.V);
if (sp) G.add(sp);
}
// ------------------------------------------------------------ roof edges
export function roofTrim(p, s, G, H) {
const ed = extDetails(s), re = roofEdges(s), fw = ed.fascia;
const fm = H.plain(ed.fasciaColor, { side: THREE.DoubleSide, roughness: 0.5 });
if (fw > 0) for (const e of [...re.eaves, ...re.rakes]) G.add(H.polyMesh([e.a, e.b, [e.b[0], e.b[1], e.b[2] - fw], [e.a[0], e.a[1], e.a[2] - fw]], fm));
if (s.roof.type === 'flat') return;
if (ed.soffit !== 'none') {
const sm = H.M(ed.soffit === 'bead' ? 'wn_bead' : 'soffit', ed.soffitColor, { side: THREE.DoubleSide, bump: 0.4 });
for (const e of re.eaves) {
const z = e.z - Math.max(fw, 3) + 0.5, o = re.o, k = e.mitre ? o : 0;
const ia = [e.a[0] - e.n[0] * o + e.d[0] * k, e.a[1] - e.n[1] * o + e.d[1] * k], ib = [e.b[0] - e.n[0] * o - e.d[0] * k, e.b[1] - e.n[1] * o - e.d[1] * k];
G.add(H.polyMesh([[e.a[0], e.a[1], z], [e.b[0], e.b[1], z], [ib[0], ib[1], z], [ia[0], ia[1], z]], sm));
}
}
if (ed.gutter === 'none') return;
const gm = H.plain(ed.gutterColor, { roughness: 0.4, metalness: 0.3 });
for (const e of re.eaves) {
const nw = [e.d[1], -e.d[0]], same = nw[0] * e.n[0] + nw[1] * e.n[1] > 0;
if (ed.gutter === 'half') {
const c = [(e.a[0] + e.b[0]) / 2 + e.n[0] * 3.25, (e.a[1] + e.b[1]) / 2 + e.n[1] * 3.25];
const m = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, e.len, 14), gm);
m.rotation.set(0, Math.atan2(e.d[1], e.d[0]), Math.PI / 2, 'YXZ');
m.position.copy(H.V(c[0], c[1], e.z - 3.5));
G.add(shadow(m));
} else {
const m = H.wallBox(e.a, e.b, 0, e.len, same ? 5.25 : -0.25, same ? -0.25 : 5.25, e.z - 5.5, e.z - 0.5, gm, false);
if (m) G.add(m);
}
}
for (const sp of re.spouts) {
const g = designAt(p, sp.x, sp.y), top = sp.top;
const box = (x, y, z0, z1, w, d) => { const m = new THREE.Mesh(new THREE.BoxGeometry(w, z1 - z0, d), gm); m.position.copy(H.V(x, y, (z0 + z1) / 2)); return shadow(m); };
G.add(box(sp.x, sp.y, g + 6, top, 3, 3));
// elbow back to the gutter, and a kick-out at the bottom
const reach = sp.out + 3.25, mx = sp.x + sp.n[0] * reach / 2, my = sp.y + sp.n[1] * reach / 2;
G.add(box(mx, my, top - 3, top, Math.abs(sp.n[0]) * reach + 3 * Math.abs(sp.n[1]), Math.abs(sp.n[1]) * reach + 3 * Math.abs(sp.n[0])));
G.add(box(sp.x + sp.n[0] * 7, sp.y + sp.n[1] * 7, g + 2, g + 6, Math.abs(sp.n[0]) * 14 + 3 * Math.abs(sp.n[1]), Math.abs(sp.n[1]) * 14 + 3 * Math.abs(sp.n[0])));
}
}
// ------------------------------------------------------------ inside finishes and ceilings
// Rooms of building s on level lvl: ceilings (with stair openings cut out) and wall finishes.
// Returns the vaulted rooms' rectangles so the building's own top ceiling can leave them open.
export function roomInside(p, s, lvl, G, H, opts) {
const base = levelBase(s, lvl), zc = base + s.wallH, vaults = [];
const holes = [];
for (const st of p.stairs || []) if (st.s === s.id && st.lvl === lvl && stairOk(s, st))
for (const h of stairLayout(st, s).holes) holes.push({ x0: s.x + h.x0, y0: s.y + h.y0, x1: s.x + h.x1, y1: s.y + h.y1 });
const slab = (r, z0, z1, mat) => { const yc = (r.y0 + r.y1) / 2, hd = (r.y1 - r.y0) / 2, m = H.wallBox([r.x0, yc], [r.x1, yc], 0, r.x1 - r.x0, hd, hd, z0, z1, mat); if (m) { m.castShadow = false; G.add(m); } };
for (const r of p.rooms.filter(r => r.s === s.id && r.lvl === lvl)) {
if (!opts.ceil) { wallFinishes(p, s, r, base, G, H); continue; }
const b = roomInner(s, r), cm = H.plain(r.ceilColor || '#f4f2ee', { roughness: 0.9 });
let type = r.ceiling || 'flat';
if (type === 'vaulted' && !(vaultOk(s, r) && opts.roof && (s.roof.type === 'gable' || s.roof.type === 'hip'))) type = 'flat';
if (type === 'vaulted') {
const ax = ridgeAxis(s), P = s.roof.pitch / 12, zp = plateZ(s);
const span = ax === 'x' ? b.y1 - b.y0 : b.x1 - b.x0, mid = ax === 'x' ? (b.y0 + b.y1) / 2 : (b.x0 + b.x1) / 2;
const S = ax === 'x' ? s.d : s.w, c0 = ax === 'x' ? s.y : s.x, roofAt = zp + (S / 2 - Math.abs(mid - (c0 + S / 2))) * P;
const rise = Math.min(span / 2 * P * 0.9, roofAt - zc - 10);
if (rise >= 12) {
vaults.push(b);
const Q = ax === 'x'
? [[[b.x0, b.y0, zc], [b.x1, b.y0, zc], [b.x1, mid, zc + rise], [b.x0, mid, zc + rise]], [[b.x1, b.y1, zc], [b.x0, b.y1, zc], [b.x0, mid, zc + rise], [b.x1, mid, zc + rise]]]
: [[[b.x0, b.y1, zc], [b.x0, b.y0, zc], [mid, b.y0, zc + rise], [mid, b.y1, zc + rise]], [[b.x1, b.y0, zc], [b.x1, b.y1, zc], [mid, b.y1, zc + rise], [mid, b.y0, zc + rise]]];
const vm = H.plain(r.ceilColor || '#f4f2ee', { side: THREE.DoubleSide, roughness: 0.9 });
for (const q of Q) G.add(H.polyMesh(q, vm));
const wm = H.plain(s.wallColor, { side: THREE.DoubleSide, roughness: 0.9 });
const ends = ax === 'x' ? [[[b.x0, b.y0, zc], [b.x0, b.y1, zc], [b.x0, mid, zc + rise]], [[b.x1, b.y1, zc], [b.x1, b.y0, zc], [b.x1, mid, zc + rise]]]
: [[[b.x0, b.y0, zc], [b.x1, b.y0, zc], [mid, b.y0, zc + rise]], [[b.x1, b.y1, zc], [b.x0, b.y1, zc], [mid, b.y1, zc + rise]]];
for (const t of ends) G.add(H.polyMesh(t, wm));
// a ridge beam
const rb = ax === 'x' ? H.wallBox([b.x0, mid], [b.x1, mid], 0, b.x1 - b.x0, 2.75, 2.75, zc + rise - 7, zc + rise, H.plain('#7a5536'))
: H.wallBox([mid, b.y0], [mid, b.y1], 0, b.y1 - b.y0, 2.75, 2.75, zc + rise - 7, zc + rise, H.plain('#7a5536'));
if (rb) G.add(rb);
} else type = 'flat';
}
if (type === 'flat' || type === 'coffered') for (const q of rectsMinus(b, holes)) slab(q, zc - 0.75, zc, cm);
if (type === 'coffered') {
const bm = H.plain(r.ceilColor ? r.ceilColor : '#f7f5f0', { roughness: 0.7 });
const nx = Math.max(2, Math.round((b.x1 - b.x0) / 48)), ny = Math.max(2, Math.round((b.y1 - b.y0) / 48));
for (let i = 0; i <= nx; i++) { const x = b.x0 + (b.x1 - b.x0) * i / nx, w = i % nx ? 2.75 : 1.5; slab({ x0: Math.max(b.x0, x - w), x1: Math.min(b.x1, x + w), y0: b.y0, y1: b.y1 }, zc - 7, zc - 0.75, bm); }
for (let j = 0; j <= ny; j++) { const y = b.y0 + (b.y1 - b.y0) * j / ny, w = j % ny ? 2.75 : 1.5; slab({ x0: b.x0, x1: b.x1, y0: Math.max(b.y0, y - w), y1: Math.min(b.y1, y + w) }, zc - 7, zc - 0.75, bm); }
}
if (type === 'tray') {
const band = Math.max(12, Math.min(30, Math.min(b.x1 - b.x0, b.y1 - b.y0) * 0.15)), drop = 10;
const inner = { x0: b.x0 + band, y0: b.y0 + band, x1: b.x1 - band, y1: b.y1 - band };
for (const q of rectsMinus(b, [inner, ...holes])) slab(q, zc - drop - 0.75, zc - drop, cm);
for (const q of rectsMinus(inner, holes)) slab(q, zc - 0.75, zc, cm);
const E = [[[inner.x0, inner.y0], [inner.x1, inner.y0]], [[inner.x1, inner.y0], [inner.x1, inner.y1]], [[inner.x1, inner.y1], [inner.x0, inner.y1]], [[inner.x0, inner.y1], [inner.x0, inner.y0]]];
for (const [a, c] of E) { const m = H.wallBox(a, c, 0, Math.hypot(c[0] - a[0], c[1] - a[1]), 0.75, 0, zc - drop - 0.75, zc, cm); if (m) G.add(m); }
}
wallFinishes(p, s, r, base, G, H);
}
return vaults;
}
function wallFinishes(p, s, r, base, G, H) {
const edges = roomEdges(p, s, r), wain = r.wainscot && r.wainscot !== 'none', wh = wain ? r.wainH || 36 : 0;
const add = (e, [t0, t1, z0, z1], mat, out, inn) => { const m = H.wallBox(e.a, e.b, t0, t1, out, inn, base + z0, base + z1, mat); if (m) { m.castShadow = false; G.add(m); } };
const wp = r.wallpaper && r.wallpaper !== 'none' ? H.M('wp_' + r.wallpaper, r.wpColor || '#d9cfbf', { bump: 0.3 }) : null;
const wc = r.wainColor || '#f4f2ee', flat = H.plain(wc, { roughness: 0.6 }), raised = H.plain(wc, { roughness: 0.55 });
for (const e of edges) {
if (wp) for (const q of bandPieces(e, 0, e.len, wh, s.wallH)) add(e, q, wp, -0.05, 0.4);
if (!wain) continue;
const bm = r.wainscot === 'bead' ? H.M('wn_bead', wc, { bump: 0.6 }) : flat;
for (const q of bandPieces(e, 0, e.len, 0, wh)) {
add(e, q, bm, -0.05, 0.55);
const [t0, t1, z0, z1] = q;
if (z1 >= wh - 0.1) add(e, [t0, t1, wh - 2.5, wh + 0.5], raised, -0.05, 1.3); // cap rail
if (z0 > 0.1 || z1 < wh - 0.1) continue; // panels and battens only on full-height stretches
if (r.wainscot === 'panel') {
const n = Math.max(1, Math.round((t1 - t0) / 24)), pw = (t1 - t0) / n;
for (let i = 0; i < n; i++) add(e, [t0 + i * pw + 3, t0 + (i + 1) * pw - 3, 5, wh - 6], raised, -0.05, 1.05);
}
if (r.wainscot === 'batten') for (let t = t0 + 1.5; t < t1 - 1; t += 16) add(e, [t, Math.min(t1, t + 3), 0, wh - 2.5], raised, -0.05, 1.3);
}
}
if (r.backsplash && r.backsplash !== 'none') {
const bs = H.M('bs_' + r.backsplash, r.bsColor || undefined, { bump: 0.5 });
for (const q of backsplashRuns(p, s, r, edges)) for (const piece of bandPieces(q.edge, q.t0, q.t1, q.z0, q.z0 + BACKSPLASH_H)) add(q.edge, piece, bs, -0.4, 0.75);
}
}
// ------------------------------------------------------------ site items
const leafCache = new Map();
const leaf = (c, k) => { const key = c + k; if (!leafCache.has(key)) leafCache.set(key, new THREE.MeshStandardMaterial({ color: new THREE.Color(c).multiplyScalar(k), roughness: 0.95, flatShading: true })); return leafCache.get(key); };
export function siteItems3d(p, G, H) {
const bark = H.plain('#5b4330', { roughness: 1 });
for (const it of p.site.items || []) {
if (it.type === 'tree') {
const d = TREES[it.kind] || TREES.oak, g = designAt(p, it.x, it.y), h = it.h, r = it.spread / 2;
const seed = [...it.id].reduce((a, c) => a * 31 + c.charCodeAt(0), 7), rnd = (i) => { const x = Math.sin(seed * 0.001 + i * 12.9898) * 43758.5453; return x - Math.floor(x); };
const trunkR = Math.max(1.5, Math.min(14, h * 0.012 + r * 0.02)), cone = d.shape === 'cone' || d.shape === 'narrow';
const tH = cone ? h * 0.25 : h * 0.45;
const tm = d.bark ? H.plain(d.bark, { roughness: 1 }) : bark;
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(trunkR * 0.7, trunkR, tH + (cone ? 0 : r * 0.3), 10), tm);
trunk.position.copy(H.V(it.x, it.y, g + (tH + (cone ? 0 : r * 0.3)) / 2 - 2));
G.add(shadow(trunk));
if (cone) {
const tiers = d.shape === 'narrow' ? 1 : 4;
for (let i = 0; i < tiers; i++) {
const z0 = g + tH * 0.6 + (h - tH * 0.6) * i / tiers * 0.75, hh = (h - tH * 0.6) * (d.shape === 'narrow' ? 1 : 0.45), rr = r * (1 - i / (tiers + 0.5));
const geo = d.shape === 'narrow' ? new THREE.SphereGeometry(1, 10, 8) : new THREE.ConeGeometry(rr, hh, 9);
const m = new THREE.Mesh(geo, leaf(d.color, 0.85 + rnd(i) * 0.3));
if (d.shape === 'narrow') { m.scale.set(r, (h - tH * 0.3) / 2, r); m.position.copy(H.V(it.x, it.y, g + tH * 0.3 + (h - tH * 0.3) / 2)); }
else m.position.copy(H.V(it.x, it.y, z0 + hh / 2));
G.add(shadow(m));
}
} else {
const cz = g + tH + (h - tH) / 2, ch = (h - tH) / 2;
for (let i = 0; i < 7; i++) {
const a = rnd(i) * Math.PI * 2, rad = i ? r * 0.45 : 0, sz = i ? r * (0.45 + rnd(i + 9) * 0.2) : r * 0.7;
const m = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 1), leaf(i % 3 === 2 && d.bloom ? d.bloom : d.color, 0.8 + rnd(i + 3) * 0.35));
m.scale.set(sz, sz * (d.shape === 'oval' ? 1.5 : 1) * ch / r, sz);
m.position.copy(H.V(it.x + Math.cos(a) * rad, it.y + Math.sin(a) * rad, cz + (i ? (rnd(i + 5) - 0.4) * ch * 0.6 : 0)));
G.add(shadow(m));
}
}
}
if (it.type === 'well') {
const g = designAt(p, it.x, it.y), steel = H.plain('#8d9296', { metalness: 0.7, roughness: 0.35 });
const c = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, 22, 16), steel); c.position.copy(H.V(it.x, it.y, g + 9)); G.add(shadow(c));
const cap = new THREE.Mesh(new THREE.CylinderGeometry(3.6, 3.6, 3, 16), H.plain('#2d2f31')); cap.position.copy(H.V(it.x, it.y, g + 21)); G.add(shadow(cap));
}
if (it.type === 'septic') {
const lid = H.plain('#3f6b3a', { roughness: 0.6 });
for (const k of [-1, 1]) {
const x = it.x, y = it.y + k * it.d * 0.3, m = new THREE.Mesh(new THREE.CylinderGeometry(12, 12, 4, 20), lid);
m.position.copy(H.V(x, y, designAt(p, x, y) + 1)); G.add(shadow(m));
}
}
if (it.type === 'drainfield') {
const pipe = H.plain('#ece9e0');
for (const [x, y] of [[it.x - it.w / 2 + 24, it.y - it.d / 2 + 24], [it.x + it.w / 2 - 24, it.y + it.d / 2 - 24]]) {
const m = new THREE.Mesh(new THREE.CylinderGeometry(2, 2, 10, 12), pipe); m.position.copy(H.V(x, y, designAt(p, x, y) + 3)); G.add(shadow(m));
}
}
if (it.type === 'trench') {
const U = UTILITIES[it.util] || UTILITIES.electric, dirt = H.M('dirt'), flag = H.plain(U.color, { side: THREE.DoubleSide });
for (let i = 1; i < it.pts.length; i++) {
const a = it.pts[i - 1], b = it.pts[i], len = Math.hypot(b[0] - a[0], b[1] - a[1]), n = Math.max(1, Math.ceil(len / 48));
for (let k = 0; k < n; k++) {
const t0 = len * k / n, t1 = len * (k + 1) / n, d = [(b[0] - a[0]) / len, (b[1] - a[1]) / len];
const mx = a[0] + d[0] * (t0 + t1) / 2, my = a[1] + d[1] * (t0 + t1) / 2, z = designAt(p, mx, my);
const m = H.wallBox(a, b, t0, t1, 9, 9, z - 1, z + 1.2, dirt);
if (m) { m.castShadow = false; G.add(m); }
}
}
for (const q of it.pts) {
const z = designAt(p, q[0], q[1]);
G.add(rod([q[0], q[1], z], [q[0], q[1], z + 30], 0.2, H.plain('#bfa57a'), H.V));
const f = new THREE.Mesh(new THREE.PlaneGeometry(5, 4), flag); f.position.copy(H.V(q[0] + 2.5, q[1], z + 28)); G.add(f);
}
}
}
}
// ------------------------------------------------------------ egress window wells
export function windowWell3d(p, s, op, G, H) {
const ww = windowWell(p, s, op);
if (!ww) return;
const { w, t0, t1, proj, top, bottom } = ww, galv = H.M('galv', undefined, { bump: 2 }), add = (m) => m && G.add(m);
add(H.wallBox(w.a, w.b, t0 - 0.6, t0, proj, 0, bottom - 3, top, galv));
add(H.wallBox(w.a, w.b, t1, t1 + 0.6, proj, 0, bottom - 3, top, galv));
add(H.wallBox(w.a, w.b, t0 - 0.6, t1 + 0.6, proj + 0.6, -proj, bottom - 3, top, galv));
add(H.wallBox(w.a, w.b, t0, t1, proj, 0, bottom - 3, bottom, H.M('gravel')));
if (ww.ladder) {
const steel = H.plain('#3a3c3f', { metalness: 0.5, roughness: 0.5 }), tm = (t0 + t1) / 2;
for (const dt of [-8, 8]) add(H.wallBox(w.a, w.b, tm + dt - 0.75, tm + dt + 0.75, proj - 0.6, 2 - proj, bottom, top - 2, steel));
for (let z = bottom + 12; z < top - 4; z += 12) add(H.wallBox(w.a, w.b, tm - 8, tm + 8, proj - 1, 3 - proj, z, z + 1.25, steel));
}
}
// Plan rectangles of every window well, cut out of the ground mesh.
export function wellHoles(p) {
const out = [];
for (const o of p.openings) {
if (o.kind !== 'egress') continue;
const s = byId(p.structures, o.s), ww = s && windowWell(p, s, o);
if (ww) out.push(ww.rect);
}
return out;
}
EOF
starter2.js: the two-story starter house.
cat > /root/build/starter2.js <<'EOF'
// "Two-story house" starter: a 36' x 28' two-story with a full basement, stacked straight stairs,
// an egress window, an attached 2-car garage, furniture, lights, finishes and a few site items.
import { FT, uid, newProject, addHouse, addGarage } from './model.js';
import { newFurniture } from './furniture.js';
import { newStair } from './stairs.js';
import { newSiteItem } from './siteitems.js';
export function twoStoryProject() {
const p = newProject('blank');
p.name = 'Two-story house';
const h = addHouse(p, 20 * FT, 40 * FT);
Object.assign(h, { name: 'House', w: 36 * FT, d: 28 * FT, floors: 2, foundation: 'basement', floor: 'oak' });
Object.assign(h.roof, { pitch: 8, overhang: 16 });
Object.assign(h.ext, { mat: 'fibercement', color: '#9aa58a', wainscot: 36, wainscotMat: 'stone', door: '#2c3e5c',
soffit: 'vented', fascia: 8, gutter: 'k' });
const s = h.id;
const R = (lvl, name, x, y, w, d, floor, extra = {}) => { const r = { id: uid(), s, lvl, name, x: x * FT, y: y * FT, w: w * FT, d: d * FT, floor, ...extra }; p.rooms.push(r); return r; };
const W = (lvl, ax, ay, bx, by) => { const w = { id: uid(), s, lvl, a: [ax * FT, ay * FT], b: [bx * FT, by * FT], t: 4.5, color: '' }; p.walls.push(w); return w.id; };
const O = (lvl, wall, kind, off, w, hh, sill, style = 'grid') => p.openings.push({ id: uid(), s, lvl, wall, kind, off, w, h: hh, sill, style });
const F = (lvl, type, x, y, rot = 0, extra = {}) => p.furniture.push(Object.assign(newFurniture(type, h, lvl, x, y, rot), extra));
// ---- basement: family room and mechanical / storage
R(-1, 'Family room', 0, 0, 20, 28, 'carpet');
R(-1, 'Mechanical / storage', 20, 0, 16, 28, 'concrete');
const b1 = W(-1, 20, 0, 20, 28);
O(-1, b1, 'door', 3 * FT, 32, 80, 0, 'flush');
O(-1, 'W', 'egress', 12 * FT, 48, 48, 40, 'casement');
F(-1, 'sofa', 120, 120, 270); F(-1, 'tvstand', 15.5, 110, 90); F(-1, 'coffee', 70, 120, 90);
F(-1, 'wheater', 410, 300); F(-1, 'shelving', 330, 30, 180);
for (const [x, y] of [[60, 70], [160, 70], [60, 230], [160, 230]]) F(-1, 'can', x, y);
p.stairs.push(newStair(h, -1, 17 * FT, 8 * FT, { shape: 'straight', dir: 0 }));
// ---- floor 1: living, entry and stairs, kitchen / dining, den, half bath, mudroom
R(0, 'Living room', 0, 0, 14, 16, 'oak', { ceiling: 'coffered' });
R(0, 'Entry and stairs', 14, 0, 6, 28, 'tile12');
R(0, 'Kitchen / dining', 20, 0, 16, 28, 'lvp', { backsplash: 'subway', bsColor: '#f2f0ea' });
R(0, 'Den', 0, 16, 8, 12, 'oak', { wainscot: 'bead', wainH: 36, wainColor: '#f4f2ee', wallpaper: 'stripe', wpColor: '#b7bfa6' });
R(0, 'Half bath', 8, 16, 6, 5, 'tile12', { wallpaper: 'floral', wpColor: '#efe2b4' });
R(0, 'Mudroom / laundry', 8, 21, 6, 7, 'tile12');
const a1 = W(0, 14, 0, 14, 28), a2 = W(0, 20, 0, 20, 28), a3 = W(0, 0, 16, 14, 16);
W(0, 8, 16, 8, 28); W(0, 8, 21, 14, 21);
O(0, a1, 'door', 3 * FT, 36, 80, 0, 'panel'); // living
O(0, a1, 'door', 17 * FT, 28, 80, 0, 'panel'); // half bath
O(0, a1, 'door', 24 * FT, 32, 80, 0, 'panel'); // mudroom
O(0, a2, 'door', 3 * FT, 36, 80, 0, 'flush'); // to the dining area
O(0, a2, 'door', 23 * FT, 36, 80, 0, 'flush'); // to the kitchen
O(0, a3, 'door', 4 * FT, 32, 80, 0, 'craftsman'); // den
O(0, 'S', 'door', 14.5 * FT, 36, 80, 0, 'craftsman');
O(0, 'S', 'window', 4 * FT, 72, 54, 30); O(0, 'S', 'window', 25 * FT, 72, 54, 30);
O(0, 'W', 'window', 18 * FT, 48, 54, 30); O(0, 'W', 'window', 4 * FT, 36, 48, 36);
O(0, 'N', 'slider', 9.5 * FT, 72, 80, 0, 'plain'); O(0, 'N', 'window', 18, 36, 42, 42); O(0, 'N', 'door', 23 * FT, 36, 80, 0, 'halfglass');
O(0, 'E', 'door', 18 * FT, 36, 80, 0, 'flush'); // into the garage
p.stairs.push(newStair(h, 0, 17 * FT, 8 * FT, { shape: 'straight', dir: 0 }));
// living room
F(0, 'sofa', 25.5, 110, 90); F(0, 'coffee', 72, 110, 90); F(0, 'tvstand', 156, 130, 270); F(0, 'armchair', 110, 30, 180);
for (const [x, y] of [[50, 50], [118, 50], [50, 150], [118, 150]]) F(0, 'can', x, y);
// kitchen / dining
F(0, 'range', 339, 316.5); F(0, 'dw', 366, 317.5); F(0, 'sink36', 396, 318); F(0, 'upper30', 339, 323.5);
F(0, 'fridge', 411, 280, 270); F(0, 'island', 336, 236); F(0, 'dining6', 330, 96, 90);
F(0, 'pendant', 312, 236); F(0, 'pendant', 360, 236); F(0, 'chandelier', 330, 96);
// den, half bath, mudroom
F(0, 'desk', 48, 318); F(0, 'officechair', 48, 292, 180); F(0, 'bookshelf', 24, 200.75, 180);
F(0, 'toilet', 108, 208.25, 180); F(0, 'vanity30', 140, 204.75, 180);
F(0, 'washer', 112, 269.25, 180); F(0, 'dryer', 140, 269.25, 180);
for (const [x, y] of [[204, 30], [204, 300], [48, 260], [132, 222], [132, 300]]) F(0, 'can', x, y);
// ---- floor 2: three bedrooms, two baths, hall
R(1, 'Bedroom 2', 0, 0, 14, 14, 'carpet', { ceiling: 'tray' });
R(1, 'Bedroom 3', 0, 14, 14, 14, 'carpet');
R(1, 'Upstairs hall', 14, 0, 6, 28, 'oak');
R(1, 'Primary bedroom', 20, 12, 16, 16, 'carpet', { ceiling: 'vaulted' });
R(1, 'Bath', 20, 0, 8, 12, 'tile12', { backsplash: 'hex', wainscot: 'panel', wainH: 36 });
R(1, 'Primary bath', 28, 0, 8, 12, 'tile', { backsplash: 'mosaic' });
const c1 = W(1, 14, 0, 14, 28), c2 = W(1, 20, 0, 20, 28), c4 = W(1, 20, 12, 36, 12);
W(1, 0, 14, 14, 14); W(1, 28, 0, 28, 12);
O(1, c1, 'door', 3 * FT, 32, 80, 0, 'panel'); O(1, c1, 'door', 22 * FT, 32, 80, 0, 'panel');
O(1, c2, 'door', 3 * FT, 30, 80, 0, 'panel'); O(1, c2, 'door', 22 * FT, 32, 80, 0, 'panel');
O(1, c4, 'door', 10 * FT, 30, 80, 0, 'barn');
O(1, 'S', 'window', 4 * FT, 48, 54, 30); O(1, 'S', 'window', 23 * FT, 30, 36, 48); O(1, 'S', 'window', 28.5 * FT, 36, 36, 48);
O(1, 'W', 'window', 18 * FT, 48, 54, 30); O(1, 'W', 'window', 4 * FT, 48, 54, 30);
O(1, 'N', 'window', 4 * FT, 48, 54, 30); O(1, 'N', 'window', 10 * FT, 48, 54, 30); O(1, 'N', 'window', 26 * FT, 48, 54, 30);
O(1, 'E', 'window', 18 * FT, 48, 54, 30);
F(1, 'queen', 48.5, 84, 90); F(1, 'nightstand', 15.5, 33, 90); F(1, 'nightstand', 15.5, 135, 90); F(1, 'dresser', 100, 155.75);
F(1, 'full', 46, 252, 90); F(1, 'desk', 120, 320); F(1, 'chest', 155.75, 190, 270);
F(1, 'king', 336, 288); F(1, 'nightstand', 286, 318); F(1, 'nightstand', 386, 318); F(1, 'dresser', 416, 180, 270);
F(1, 'tub', 288, 21, 180); F(1, 'toilet', 319.75, 100, 270); F(1, 'vanity30', 252.75, 110, 90);
F(1, 'shower', 408, 24); F(1, 'vanity60', 349.25, 80, 90); F(1, 'toilet', 412, 90, 270);
F(1, 'fan', 84, 84); F(1, 'fan', 84, 252); F(1, 'fan', 336, 240);
for (const [x, y] of [[204, 40], [204, 260], [288, 80], [384, 80]]) F(1, 'can', x, y);
// ---- attached garage and the lot
const g = addGarage(p, 2, h);
const G = (type, x, y, rot = 0) => p.furniture.push(newFurniture(type, g, 0, x, y, rot));
G('car', 100, 144); G('suv', 196, 140); G('bench', 144, 266.5); G('shoplight', 100, 144, 90); G('shoplight', 196, 144, 90);
p.site.items.push(newSiteItem('tree', 8 * FT, 14 * FT, { kind: 'maple', size: 'medium' }));
p.site.items.push(newSiteItem('tree', 46 * FT, 22 * FT, { kind: 'dogwood', size: 'small' }));
p.site.items.push(newSiteItem('tree', 88 * FT, 125 * FT, { kind: 'spruce', size: 'large' }));
p.site.items.push(newSiteItem('tree', 20 * FT, 118 * FT, { kind: 'oak', size: 'medium' }));
p.site.items.push(newSiteItem('trench', 0, 0, { util: 'electric', pts: [[30 * FT, 0], [30 * FT, 40 * FT]] }));
p.site.items.push(newSiteItem('trench', 0, 0, { util: 'water', pts: [[44 * FT, 0], [44 * FT, 40 * FT]] }));
return p;
}
EOF
add-kuma-monitors.py: adds the two Uptime Kuma monitors (runs inside CT 202, which already has the Kuma login).
cat > /root/build/add-kuma-monitors.py <<'EOF'
"""Add Uptime Kuma monitors for Build Designer (build.503mail.com, CT 310).
Run inside CT 202 (creator-signup), which already has uptime_kuma_api and the
Kuma credentials in signup_handler.py:
pct push 202 /root/build/add-kuma-monitors.py /root/build-kuma.py
pct exec 202 -- /opt/creator-signup/venv/bin/python /root/build-kuma.py <CT 310 IP>
Idempotent: skips monitors that already exist by name.
"""
import sys
sys.path.insert(0, "/opt/creator-signup")
import signup_handler as sh
from uptime_kuma_api import UptimeKumaApi, MonitorType
BACKEND_IP = sys.argv[1] # CT 310 (build-designer)'s LAN IP
TAG_NAME = "Personal Site"
MONITORS = [
# /login answers 200 without signing in, and only when Caddy, the app and its pages all work
dict(type=MonitorType.KEYWORD, name="build.503mail.com — Build Designer (house + garage designer)",
url="https://build.503mail.com/login", keyword="Build Designer", interval=60),
# /healthz also runs a database query
dict(type=MonitorType.KEYWORD, name=f"{BACKEND_IP}:8000 — Build Designer LXC (backend for build.503mail.com)",
url=f"http://{BACKEND_IP}:8000/healthz", keyword="ok", interval=60),
]
api = UptimeKumaApi(sh.UPTIME_KUMA_URL)
api.login(sh.UPTIME_KUMA_USER, sh.UPTIME_KUMA_PASS)
existing = {m["name"] for m in api.get_monitors()}
tag = next(t for t in api.get_tags() if t["name"] == TAG_NAME)
for m in MONITORS:
if m["name"] in existing:
print("exists:", m["name"])
continue
monitor_id = api.add_monitor(**m)["monitorID"]
api.add_monitor_tag(tag_id=tag["id"], monitor_id=monitor_id, value="")
print("added:", monitor_id, m["name"])
api.disconnect()
EOF
main.js: ties it together: saving, undo, the properties forms, site and grading, materials, printing and projects.
cat > /root/build/main.js <<'EOF'
// App shell: projects, saving, undo, panels and tabs.
import { FT, uid, fmtLen, parseLen, sqft, newProject, addHouse, addGarage, byId, levelsOf, levelName, updateAutoFfe,
extWalls, SIDE_NAMES, SIDING, ROOFING, ROOF_TYPES, FLOORING, FOUNDATIONS, FOUNDATION_WALL, OPENINGS, earthwork,
materials, describe, groundAt, designAt, levelBase, addWing, attachTo, DOOR_STYLES, GARAGE_STYLES, isExtWall, doorColor } from './model.js';
import { PlanEditor } from './plan2d.js';
import { drawElevation, SIDES } from './elev.js';
import { SCALES, PAPERS, sheetList, fitsText, buildPrint, setPlanOptions } from './print.js';
import { FURN, FURN_CATS, KELVIN, newFurniture, backToWall, rotatedSize, sampleFurniture } from './furniture.js';
import { EGRESS_STYLES, egressCheck, windowWell, SOFFITS, GUTTERS, BONDS, ACCENTS, WALLPAPER, BACKSPLASH, WAINSCOT_IN, CEILINGS,
extDetails, polyLen } from './model.js';
import { STAIR_SHAPES, STAIR_DIRS, STAIR_TURNS, newStair, stairOk, stairText, stairLayout } from './stairs.js';
import { TREES, TREE_SIZES, UTILITIES, SITE_ITEM_TYPES, newSiteItem, itemName, itemText, siteChecks } from './siteitems.js';
import { applyDetectedRooms } from './rooms.js';
import { finishText, vaultOk, finishAreas } from './finishes.js';
import { estimate, PRICES, PRICE_GROUPS, price } from './estimate.js';
import { twoStoryProject } from './starter2.js';
// new projects; the house + garage starter comes furnished so the library is easy to find
function starter(kind) {
if (kind === 'twostory') return twoStoryProject();
const p = newProject(kind);
if (kind === 'house+garage') sampleFurniture(p);
return p;
}
const $ = (s) => document.querySelector(s);
const CSRF = document.querySelector('meta[name=csrf]').content;
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const store = { get(k) { try { return localStorage.getItem(k); } catch { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch { } } };
async function api(method, url, body) {
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': CSRF }, body: body ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({ error: `Server error ${r.status}` }));
if (!r.ok) { const e = new Error(j.error || `Error ${r.status}`); e.status = r.status; e.body = j; throw e; }
return j;
}
const app = {
project: null, id: null, role: 'owner', updated: null, sel: null, selKind: null,
undo: [], redo: [], dirty: false, tab: 'plan', view3d: null,
ui: { roomWalls: $('#roomWalls') },
furnType() { return $('#furnType').value; },
stairOpts() { return { shape: $('#stairShape').value, turn: $('#stairTurn').value, dir: +$('#stairDir').value, w: parseLen($('#stairW').value) || 36 }; },
siteOpts() { return { kind: $('#treeKind').value, size: $('#treeSize').value }; },
trenchUtil() { return $('#trenchUtil').value; },
say(msg) { const l = $('#live'); l.textContent = ''; setTimeout(() => { l.textContent = msg; }, 30); },
snapshot() { return JSON.stringify(this.project); },
changed(msg) {
updateAutoFfe(this.project);
this.undo.push(this.last); if (this.undo.length > 80) this.undo.shift();
this.redo = [];
this.last = this.snapshot();
this.dirty = true;
this.refresh();
if (msg) this.say(msg);
scheduleSave();
},
select(o, kind) {
this.sel = o; this.selKind = o ? kind || kindOf(o) : null;
editor.render(); renderList(); renderProps();
},
addRoom(s, lvl, x, y, w, d, walls) {
const r = { id: uid(), s: s.id, lvl, name: 'Room', x, y, w, d, floor: s.kind === 'garage' ? 'concrete' : 'oak' };
this.project.rooms.push(r);
if (walls) {
const edges = [[[x, y], [x + w, y]], [[x + w, y], [x + w, y + d]], [[x + w, y + d], [x, y + d]], [[x, y + d], [x, y]]];
for (const [a, b] of edges) {
const onExt = (a[0] === b[0] && (a[0] <= 0 || a[0] >= s.w)) || (a[1] === b[1] && (a[1] <= 0 || a[1] >= s.d));
const dup = this.project.walls.some(w2 => w2.s === s.id && w2.lvl === lvl && sameSeg(w2, a, b));
if (!onExt && !dup) this.project.walls.push({ id: uid(), s: s.id, lvl, a, b, t: 4.5, color: '' });
}
}
this.select(r, 'room');
this.changed(`Room added, ${fmtLen(w, { words: true })} by ${fmtLen(d, { words: true })}. Name it in the properties panel.`);
},
refresh() {
editor.render(); renderList(); renderProps(); renderLevels();
$('#describe').textContent = describe(this.project);
if (this.tab === 'three') build3d();
if (this.tab === 'elev') renderElev();
if (this.tab === 'site') renderSite();
if (this.tab === 'mat') renderMaterials();
if (this.tab === 'print') renderPrintTab();
$('#btnUndo').disabled = !this.undo.length; $('#btnRedo').disabled = !this.redo.length;
},
};
window.buildApp = app;
const sameSeg = (w, a, b) => (w.a[0] === a[0] && w.a[1] === a[1] && w.b[0] === b[0] && w.b[1] === b[1]) || (w.a[0] === b[0] && w.a[1] === b[1] && w.b[0] === a[0] && w.b[1] === a[1]);
function kindOf(o) {
const p = app.project;
if (p.structures.includes(o)) return 's'; if (p.rooms.includes(o)) return 'room';
if (p.walls.includes(o)) return 'wall'; if (p.openings.includes(o)) return 'op';
if (p.furniture.includes(o)) return 'furn';
if (p.stairs.includes(o)) return 'stair';
if (p.site.items.includes(o)) return 'site';
return null;
}
const listOf = (k) => { const p = app.project; return { s: p.structures, room: p.rooms, wall: p.walls, op: p.openings, furn: p.furniture, stair: p.stairs, site: p.site.items }[k]; };
const editor = new PlanEditor($('#planSvg'), app);
// ------------------------------------------------------------ load / save
function loadProject(data, meta = {}) {
app.project = data;
for (const k of ['structures', 'walls', 'rooms', 'openings', 'furniture', 'stairs']) data[k] ||= [];
data.site.items ||= []; data.prices ||= {};
app.id = meta.id ?? null; app.role = meta.role || 'owner'; app.updated = meta.updated ?? null;
app.undo = []; app.redo = []; app.sel = null; app.dirty = false;
updateAutoFfe(data);
app.last = app.snapshot();
$('#projName').value = data.name || '';
$('#projName').disabled = app.role === 'view';
if (app.view3d) app.view3d._homed = false;
renderLevels();
editor.view = levelsOf(data.structures[0] || { foundation: 'slab', floors: 1 }).includes(0) ? 0 : 'site';
$('#levelSel').value = String(editor.view);
editor.fit();
app.refresh();
setStatus(app.role === 'view' ? 'View only (shared with you)' : app.id ? 'Saved' : 'Not saved yet');
if (app.id) store.set('buildLast', app.id);
}
let saveT = null, saving = false;
function scheduleSave() {
if (app.role === 'view') return;
setStatus('Unsaved changes');
clearTimeout(saveT);
saveT = setTimeout(save, 2500);
}
function setStatus(t) { $('#saveStatus').textContent = t; }
async function save(force = false) {
if (app.role === 'view' || saving) return;
clearTimeout(saveT);
saving = true;
app.project.name = $('#projName').value.trim() || 'Untitled project';
try {
if (!app.id) {
const r = await api('POST', '/api/projects', { name: app.project.name, data: app.project });
app.id = r.id; app.updated = r.updated; store.set('buildLast', app.id);
} else {
const r = await api('PUT', `/api/projects/${app.id}`, { name: app.project.name, data: app.project, base_updated: app.updated, force });
app.updated = r.updated;
}
app.dirty = false;
setStatus('Saved ' + new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }));
} catch (e) {
if (e.body?.conflict) {
if (confirm('Someone else saved this project after you opened it. OK = keep YOUR version and overwrite theirs. Cancel = load their version (your recent changes are lost).')) { saving = false; return save(true); }
saving = false; return openProject(app.id);
}
setStatus('Not saved: ' + e.message);
app.say('Save failed: ' + e.message);
} finally { saving = false; }
}
async function openProject(id) {
try {
const r = await api('GET', `/api/projects/${id}`);
loadProject(r.data, r);
app.shares = r.shares;
app.say(`Opened ${r.name}`);
} catch (e) { alert('Could not open the project: ' + e.message); }
}
async function startup() {
let list = [];
try { list = (await api('GET', '/api/projects')).projects; } catch (e) { setStatus(e.message); }
const last = +store.get('buildLast');
const pick = list.find(p => p.id === last) || list[0];
if (pick) await openProject(pick.id);
else { loadProject(starter('house+garage')); save(); }
}
addEventListener('beforeunload', (e) => { if (app.dirty && app.role !== 'view') { save(); e.preventDefault(); } });
// ------------------------------------------------------------ header
$('#btnSave').onclick = () => save();
$('#projName').onchange = () => { app.project.name = $('#projName').value; app.changed('Project renamed'); };
function undo() { if (!app.undo.length) return; app.redo.push(app.last); app.project = JSON.parse(app.undo.pop()); app.last = app.snapshot(); app.sel = null; app.dirty = true; app.refresh(); scheduleSave(); app.say('Undone'); }
function redo() { if (!app.redo.length) return; app.undo.push(app.last); app.project = JSON.parse(app.redo.pop()); app.last = app.snapshot(); app.sel = null; app.dirty = true; app.refresh(); scheduleSave(); app.say('Redone'); }
$('#btnUndo').onclick = undo; $('#btnRedo').onclick = redo;
addEventListener('keydown', (e) => {
const inField = /INPUT|SELECT|TEXTAREA/.test(document.activeElement?.tagName);
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !inField) { e.preventDefault(); e.shiftKey ? redo() : undo(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y' && !inField) { e.preventDefault(); redo(); }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); }
if (app.tab !== 'plan' || inField) return;
if (e.key === 'Escape') { editor.temp = null; setTool('select'); editor.render(); }
if ((e.key === 'Delete' || e.key === 'Backspace') && app.sel && document.activeElement === $('#planSvg')) { e.preventDefault(); removeSel(); }
if (e.key.toLowerCase() === 'r' && !e.ctrlKey && !e.metaKey && app.selKind === 'furn' && app.role !== 'view') {
e.preventDefault();
turn(app.sel, e.shiftKey ? 15 : 90);
}
if (e.key.startsWith('Arrow') && app.sel && document.activeElement === $('#planSvg')) {
e.preventDefault();
const st = editor.snap * (e.shiftKey ? 4 : 1), dx = e.key === 'ArrowRight' ? st : e.key === 'ArrowLeft' ? -st : 0, dy = e.key === 'ArrowUp' ? st : e.key === 'ArrowDown' ? -st : 0;
const o = app.sel;
if (app.selKind === 'op') o.off = Math.max(0, o.off + dx + dy);
else if (app.selKind === 'wall') { o.a = [o.a[0] + dx, o.a[1] + dy]; o.b = [o.b[0] + dx, o.b[1] + dy]; }
else if (o.pts) o.pts = o.pts.map(q => [q[0] + dx, q[1] + dy]);
else { o.x += dx; o.y += dy; }
app.changed(app.selKind === 'op' ? `${fmtLen(o.off)} from wall start` : app.selKind === 'wall' ? 'Wall moved' : o.pts ? 'Trench moved' : `${fmtLen(o.x)} across, ${fmtLen(o.y)} up`);
}
});
// ------------------------------------------------------------ tabs
for (const b of document.querySelectorAll('.tabs button')) b.onclick = () => showTab(b.dataset.tab);
function showTab(t) {
app.tab = t;
for (const b of document.querySelectorAll('.tabs button')) b.setAttribute('aria-selected', b.dataset.tab === t);
for (const s of document.querySelectorAll('.tab')) s.classList.toggle('on', s.id === 'tab-' + t);
app.refresh();
if (t === 'plan') editor.render();
}
// ------------------------------------------------------------ plan toolbar
function setTool(t) {
editor.tool = t; editor.temp = null;
for (const b of document.querySelectorAll('#tools button')) b.setAttribute('aria-pressed', b.dataset.tool === t);
const msg = {
furn: `Furniture tool. Click on the plan to place: ${FURN[app.furnType()].name}. Pick a different item in the Furniture and lights list.`,
stairs: 'Stairs tool. Click inside a building where the front-left corner of the stairs goes. Shape and direction come from the Stairs section.',
tree: 'Tree tool. On the site plan, click where the trunk goes. Kind and size come from the Lot items section.',
trench: 'Trench tool. On the site plan, click the start, click each bend, double-click to finish. The utility comes from the Lot items section.',
egress: 'Egress window tool. Click on an outside basement wall.',
}[t];
app.say(msg || `${t} tool`);
}
for (const b of document.querySelectorAll('#tools button')) b.onclick = () => setTool(b.dataset.tool);
$('#snapSel').onchange = (e) => { editor.snap = +e.target.value; };
$('#zoomIn').onclick = () => editor.zoomAt(1 / 1.3); $('#zoomOut').onclick = () => editor.zoomAt(1.3); $('#zoomFit').onclick = () => editor.fit();
$('#levelSel').onchange = (e) => { editor.view = e.target.value === 'site' ? 'site' : +e.target.value; app.sel = null; editor.fit(); renderList(); renderProps(); };
function allLevels() {
const s = new Set();
for (const st of app.project.structures) for (const l of levelsOf(st)) s.add(l);
if (!s.size) s.add(0);
return [...s].sort((a, b) => a - b);
}
function renderLevels() {
const L = allLevels();
const opts = `<option value="site">Site plan (whole lot)</option>` + L.map(l => `<option value="${l}">${levelName(l)}</option>`).join('');
const sel = $('#levelSel'), v = String(editor.view);
sel.innerHTML = opts;
sel.value = [...sel.options].some(o => o.value === v) ? v : 'site';
if (sel.value !== v) editor.view = sel.value === 'site' ? 'site' : +sel.value;
const ml = $('#maxLevel'), mv = ml.value;
ml.innerHTML = L.map(l => `<option value="${l}">${levelName(l)}</option>`).join('') + `<option value="99">All + roof</option>`;
ml.value = mv && [...ml.options].some(o => o.value === mv) ? mv : '99';
const wl = $('#walkLevel'), wv = wl.value;
wl.innerHTML = L.map(l => `<option value="${l}">${levelName(l)}</option>`).join('') + `<option value="out">Outside</option>`;
wl.value = wv && [...wl.options].some(o => o.value === wv) ? wv : (L.includes(0) ? '0' : String(L[0]));
}
$('#addHouse').onclick = () => {
const p = app.project, x = p.structures.length ? Math.max(...p.structures.map(s => s.x + s.w)) + 10 * FT : 20 * FT;
const h = addHouse(p, Math.min(x, p.site.w - 40 * FT), 40 * FT);
app.select(h, 's'); app.changed('House added, 40 by 28 feet. Change its size in the properties panel.');
};
$('#addGarage').onclick = () => {
const p = app.project, cars = +$('#garageCars').value;
const house = p.structures.find(s => s.kind === 'house');
const g = addGarage(p, cars, house && confirm('Attach the garage to the right side of the house? (Cancel = free-standing)') ? house : null);
if (!house || g.x === 10 * FT) { g.x = Math.max(0, p.site.w - g.w - p.site.setback.side); g.y = p.site.setback.front + 20 * FT; }
app.select(g, 's'); app.changed(`${cars}-car garage added`);
};
$('#addWing').onclick = () => {
const p = app.project, main = app.selKind === 's' && app.sel.kind === 'house' ? app.sel : p.structures.find(s => s.kind === 'house');
if (!main) { app.say('Add a house first.'); return; }
const g = addWing(p, main);
app.select(g, 's');
app.changed(`Wing added to the back of ${main.name}, 16 by 20 feet, making an L shape. The wall they share is drawn once. Change its size or move it with Attach to.`);
};
// ------------------------------------------------------------ furniture and lights
const furnTypeOptions = (cur, cat) => Object.entries(FURN_CATS).filter(([c]) => !cat || c === cat).map(([c, label]) =>
`<optgroup label="${esc(label)}">${Object.entries(FURN).filter(([, d]) => d.cat === c).map(([k, d]) => `<option value="${k}" ${k === cur ? 'selected' : ''}>${esc(d.name)} (${fmtLen(d.w)} x ${fmtLen(d.d)})</option>`).join('')}</optgroup>`).join('');
const fillFurnTypes = () => { $('#furnType').innerHTML = furnTypeOptions(null, $('#furnCat').value); };
function initFurnUI() { $('#furnCat').innerHTML = opt(FURN_CATS, 'living'); fillFurnTypes(); }
$('#furnCat').onchange = () => { fillFurnTypes(); if (editor.tool === 'furn') setTool('furn'); };
$('#furnType').onchange = () => { if (editor.tool === 'furn') setTool('furn'); };
// Inner face of a room edge: 6" in from an outside wall, half an inside wall otherwise.
function roomBox(s, r) {
if (!r) return { name: s.name, x0: 6, y0: 6, x1: s.w - 6, y1: s.d - 6 };
const e = (out) => (out ? 6 : 2.25);
return { name: r.name, x0: r.x + e(r.x <= 0), y0: r.y + e(r.y <= 0), x1: r.x + r.w - e(r.x + r.w >= s.w), y1: r.y + r.d - e(r.y + r.d >= s.d) };
}
function roomOf(f) {
return app.project.rooms.find(r => r.s === f.s && r.lvl === f.lvl && f.x >= r.x && f.x <= r.x + r.w && f.y >= r.y && f.y <= r.y + r.d);
}
function furnWhere(f) {
const s = byId(app.project.structures, f.s);
if (!s) return '';
const b = roomBox(s, roomOf(f)), [W, D] = rotatedSize(f);
return `In ${b.name}. Space to its left side ${fmtLen(f.x - W / 2 - b.x0)}, right ${fmtLen(b.x1 - f.x - W / 2)}, front ${fmtLen(f.y - D / 2 - b.y0)}, back ${fmtLen(b.y1 - f.y - D / 2)}. Takes up ${fmtLen(W)} by ${fmtLen(D)} of floor.`;
}
// spot: middle, back, front, left or right (against that wall of the room, facing into the room)
function placeInRoom(f, s, r, spot) {
const b = roomBox(s, r), half = f.d / 2 + 0.5;
f.x = (b.x0 + b.x1) / 2; f.y = (b.y0 + b.y1) / 2; f.rot = 0;
if (spot === 'back') f.y = b.y1 - half;
if (spot === 'front') { f.rot = 180; f.y = b.y0 + half; }
if (spot === 'left') { f.rot = 90; f.x = b.x0 + half; }
if (spot === 'right') { f.rot = 270; f.x = b.x1 - half; }
}
$('#addFurn').onclick = () => {
const p = app.project, lvl = editor.view;
if (lvl === 'site') { app.say('Pick a floor in the Showing list first.'); return; }
let r = app.selKind === 'room' && app.sel.lvl === lvl ? app.sel : null;
const s = r ? byId(p.structures, r.s) : app.selKind === 's' && levelsOf(app.sel).includes(lvl) ? app.sel
: app.selKind === 'furn' ? byId(p.structures, app.sel.s) : p.structures.find(st => levelsOf(st).includes(lvl));
if (!s) { app.say('Add a building first.'); return; }
if (!r && app.selKind === 'furn') r = roomOf(app.sel);
const f = newFurniture(app.furnType(), s, lvl, 0, 0);
const spot = $('#furnSpot').value;
placeInRoom(f, s, r, FURN[f.type].mount === 'ceiling' ? 'middle' : spot);
p.furniture.push(f);
app.select(f, 'furn');
app.changed(`${f.name} added ${spot === 'middle' || FURN[f.type].mount === 'ceiling' ? 'in the middle of' : 'against the ' + spot + ' wall of'} ${r ? r.name : s.name}.`);
$('#props').querySelector('input,select')?.focus();
};
// ------------------------------------------------------------ stairs, rooms from walls, lot items
function initPhase3UI() {
$('#stairShape').innerHTML = opt(STAIR_SHAPES, 'straight');
$('#stairTurn').innerHTML = opt(STAIR_TURNS, 'right');
$('#stairDir').innerHTML = opt(STAIR_DIRS, '0');
const types = { tree: 'Tree', well: 'Well', septic: 'Septic tank', drainfield: 'Septic drain field', trench: 'Utility trench' };
$('#siteType').innerHTML = opt(types, 'tree');
$('#treeKind').innerHTML = opt(TREES, 'oak');
$('#treeSize').innerHTML = opt(TREE_SIZES, 'medium');
$('#trenchUtil').innerHTML = opt(UTILITIES, 'electric');
showSiteOpts();
}
function showSiteOpts() {
const t = $('#siteType').value;
for (const e of document.querySelectorAll('.treeOpt')) e.hidden = t !== 'tree';
for (const e of document.querySelectorAll('.trenchOpt')) e.hidden = t !== 'trench';
const to = $('#trenchTo'), v = to.value;
to.innerHTML = `<option value="pt">The point typed below</option>` + app.project?.structures.map(s => `<option value="${s.id}">The front wall of ${esc(s.name)}</option>`).join('');
if ([...to.options].some(o => o.value === v)) to.value = v;
for (const e of document.querySelectorAll('.trenchEnd')) if (t === 'trench') e.hidden = to.value !== 'pt';
}
$('#siteType').onchange = showSiteOpts;
$('#trenchTo').onchange = showSiteOpts;
$('#addSite').onclick = () => {
const p = app.project, t = $('#siteType').value, x = parseLen($('#siteX').value), y = parseLen($('#siteY').value);
if (x == null || y == null) { app.say('Type the distances in feet, like 30 or 30\' 6".'); return; }
let it;
if (t === 'trench') {
const to = $('#trenchTo').value, s = byId(p.structures, to);
let end = [parseLen($('#siteX2').value), parseLen($('#siteY2').value)];
if (s) end = [Math.min(Math.max(x, s.x + 12), s.x + s.w - 12), s.y];
if (end.some(v => v == null)) { app.say('Type where the trench ends.'); return; }
it = newSiteItem('trench', 0, 0, { util: app.trenchUtil(), pts: s && end[0] !== x ? [[x, y], [end[0], y], end] : [[x, y], end] });
} else it = newSiteItem(t, x, y, app.siteOpts());
p.site.items.push(it);
if (editor.view !== 'site') { editor.view = 'site'; $('#levelSel').value = 'site'; editor.fit(); }
app.select(it, 'site');
app.changed(`${itemText(it)} Added.`);
$('#props').querySelector('input,select')?.focus();
};
$('#addStairs').onclick = () => {
const p = app.project, lvl = editor.view;
if (lvl === 'site') { app.say('Pick a floor in the Showing list first.'); return; }
const r = app.selKind === 'room' && app.sel.lvl === lvl ? app.sel : null;
const s = r ? byId(p.structures, r.s) : app.selKind === 's' ? app.sel : p.structures.find(st => levelsOf(st).includes(lvl) && levelsOf(st).includes(lvl + 1));
if (!s) { app.say('No building has a floor above this one. Set Floors to 2 or more.'); return; }
const st = newStair(s, lvl, 0, 0, app.stairOpts());
if (!stairOk(s, st)) { app.say(`${s.name} has no floor above ${levelName(lvl)}. Set Floors to 2 or more, or add a basement.`); return; }
const L = stairLayout(st, s), bw = L.box.x1 - L.box.x0, bd = L.box.y1 - L.box.y0;
// into the selected room's back-left corner, else the middle of the building
if (r) { st.x = r.x + (r.x <= 0 ? 6 : 2.25); st.y = Math.max(r.y, r.y + r.d - bd - (r.y + r.d >= s.d ? 6 : 2.25)); }
else { st.x = Math.round((s.w - bw) / 2); st.y = Math.round((s.d - bd) / 2); }
p.stairs.push(st);
app.select(st, 'stair');
app.changed(`${stairText(st, s)} Placed in ${r ? r.name : 'the middle of ' + s.name}. Move it with the From left and From front boxes.`);
$('#props').querySelector('input,select')?.focus();
};
$('#detectRooms').onclick = () => {
const p = app.project, lvl = editor.view;
if (lvl === 'site') { app.say('Pick a floor in the Showing list first.'); return; }
const ss = app.selKind === 's' && levelsOf(app.sel).includes(lvl) ? [app.sel] : p.structures.filter(s => levelsOf(s).includes(lvl));
const msgs = ss.map(s => applyDetectedRooms(p, s, lvl).text);
app.sel = null;
app.changed(msgs.join(' ') || 'No buildings on this floor.');
};
function turn(f, deg) {
f.rot = ((f.rot || 0) + deg) % 360;
app.changed(`${f.name} turned to ${f.rot} degrees`);
}
function dupFurn(f) {
const c = { ...f, id: uid(), x: f.x + 12, y: f.y - 12 };
app.project.furniture.push(c);
app.select(c, 'furn'); app.changed(`${c.name} copied, 1 foot right and 1 foot forward`);
}
function renderList() {
const p = app.project, v = editor.view, ul = $('#objList');
const item = (o, kind, label, cls = '') => `<li class="${cls}"><button data-k="${kind}" data-id="${o.id}" aria-current="${app.sel === o}">${esc(label)}</button></li>`;
for (const e of document.querySelectorAll('.floorOnly')) e.hidden = v === 'site';
for (const e of document.querySelectorAll('.siteOnly')) e.hidden = v !== 'site';
if (v === 'site' && !$('.siteOnly').contains(document.activeElement)) showSiteOpts();
let h = '';
if (v === 'site') for (const it of p.site.items) h += item(it, 'site', it.type === 'trench' ? `${itemName(it)} ${fmtLen(polyLen(it.pts))}` : it.type === 'tree' ? `${itemName(it)}, ${fmtLen(it.h)} tall` : itemName(it));
for (const s of p.structures) {
h += item(s, 's', `${s.name} (${fmtLen(s.w)} x ${fmtLen(s.d)})`);
if (v === 'site' || !levelsOf(s).includes(v)) continue;
for (const r of p.rooms.filter(r => r.s === s.id && r.lvl === v)) h += item(r, 'room', `Room: ${r.name} ${fmtLen(r.w)} x ${fmtLen(r.d)}`, 'sub');
for (const o of p.openings.filter(o => o.s === s.id && o.lvl === v)) h += item(o, 'op', `${OPENINGS[o.kind].name} ${fmtLen(o.w)} on ${wallLabel(o)}`, 'sub');
p.walls.filter(w => w.s === s.id && w.lvl === v).forEach((w, i) => { h += item(w, 'wall', `Wall ${i + 1}, ${fmtLen(Math.hypot(w.b[0] - w.a[0], w.b[1] - w.a[1]))}`, 'sub'); });
for (const st of p.stairs.filter(st => st.s === s.id && (st.lvl === v || st.lvl + 1 === v)))
h += item(st, 'stair', `${STAIR_SHAPES[st.shape] || 'Straight'} stairs ${st.lvl === v ? 'up to ' + levelName(st.lvl + 1) : 'down to ' + levelName(st.lvl)}`, 'sub');
for (const f of p.furniture.filter(f => f.s === s.id && f.lvl === v)) {
const r = roomOf(f), d = FURN[f.type];
h += item(f, 'furn', `${d?.light ? 'Light' : 'Furniture'}: ${f.name}${r ? ' in ' + r.name : ''}${d?.light && f.on === false ? ' (off)' : ''}`, 'sub');
}
}
ul.innerHTML = h || '<li class="muted">Nothing here yet. Add a house or garage.</li>';
for (const b of ul.querySelectorAll('button')) b.onclick = () => {
app.select(byId(listOf(b.dataset.k), b.dataset.id), b.dataset.k);
$('#props').querySelector('input,select')?.focus();
};
}
function wallLabel(o) {
if ('NESW'.includes(o.wall) && o.wall.length === 1) return SIDE_NAMES[o.wall].toLowerCase() + ' wall';
const w = byId(app.project.walls, o.wall);
return w ? 'inside wall' : 'missing wall';
}
// ------------------------------------------------------------ properties panel
const opt = (obj, cur) => Object.entries(obj).map(([k, v]) => `<option value="${k}" ${k === String(cur) ? 'selected' : ''}>${esc(typeof v === 'string' ? v : v.name)}</option>`).join('');
const lenF = (label, key, val, hint = '') => `<label>${label}${hint ? ` <span class="small">${hint}</span>` : ''}<input data-len="${key}" value="${esc(fmtLen(val))}"></label>`;
const txtF = (label, key, val) => `<label>${label}<input data-txt="${key}" value="${esc(val)}"></label>`;
const selF = (label, key, obj, cur) => `<label>${label}<select data-sel="${key}">${opt(obj, cur)}</select></label>`;
// Common real-world colors, offered next to every color picker as a labeled list plus clickable swatches.
const SW = {
siding: { White: '#f4f2ee', Almond: '#e9dfc8', Clay: '#c9b79c', Sandstone: '#c8b48e', 'Light gray': '#c9c9c4', 'Pale blue': '#a9bccb', Sage: '#9aa58a', Gray: '#8e9192', Charcoal: '#4a4d50', Black: '#222222', 'Barn red': '#7c2a22', Navy: '#2c3e5c', 'Forest green': '#2f4a35', Bronze: '#5b4630' },
roof: { Charcoal: '#4a4d52', Black: '#222222', 'Weathered wood': '#6d6458', Brown: '#5b4630', 'Slate gray': '#6c7378', 'Barn red': '#7c2a22', 'Forest green': '#2f4a35', Navy: '#2c3e5c', Galvalume: '#b8bcbf', White: '#f4f2ee' },
door: { White: '#f4f2ee', Almond: '#e9dfc8', Sandstone: '#c8b48e', Gray: '#8e9192', Charcoal: '#4a4d50', Black: '#222222', 'Barn red': '#7c2a22', Navy: '#2c3e5c', 'Forest green': '#2f4a35', Bronze: '#5b4630', 'Light oak': '#a8763e', Walnut: '#5e3b22', Mustard: '#d9a62e', Teal: '#2f6f73' },
paint: { White: '#f4f2ee', Alabaster: '#ede9df', 'Agreeable gray': '#d1cbc1', 'Repose gray': '#ccc9c0', Greige: '#b9b0a1', 'Accessible beige': '#d1c7b8', 'Pale blue': '#c9d6df', Sage: '#b7bfa6', 'Soft yellow': '#efe2b4', Charcoal: '#4a4d50' },
};
const swatchSet = (key) => /roof/.test(key) ? SW.roof : /door|^color$/.test(key) ? SW.door : /wallColor|paint/.test(key) ? SW.paint : SW.siding;
const colF = (label, key, val, set = swatchSet(key)) => {
const name = Object.entries(set).find(([, h]) => h.toLowerCase() === String(val).toLowerCase())?.[0];
return `<div class="colfield"><label class="row">${label} <input type="color" data-col="${key}" value="${esc(val)}"></label>
<label class="small">Common ${esc(label.toLowerCase())} colors<select data-swsel="${key}"><option value="">${name ? esc(name) : 'Custom color'}</option>${Object.entries(set).map(([n, h]) => `<option value="${h}">${esc(n)}</option>`).join('')}</select></label>
<span class="swatches" role="group" aria-label="${esc(label)} swatches">${Object.entries(set).map(([n, h]) => `<button type="button" tabindex="-1" data-sw="${key}" data-hex="${h}" title="${esc(n)}" aria-label="${esc(n)}" style="background:${h}"><span class="sr">${esc(n)}</span></button>`).join('')}</span></div>`;
};
const numF = (label, key, val, min, max, step = 1) => `<label>${label}<input type="number" data-num="${key}" value="${val}" min="${min}" max="${max}" step="${step}"></label>`;
function getPath(o, k) { return k.split('.').reduce((a, b) => a?.[b], o); }
function setPath(o, k, v) { const ks = k.split('.'); const last = ks.pop(); ks.reduce((a, b) => a[b], o)[last] = v; }
function renderProps() {
const el = $('#props'), o = app.sel, k = app.selKind, p = app.project;
if (!o || !kindOf(o)) { el.innerHTML = '<p class="muted">Select something on the plan or in the list to change it.</p>'; return; }
let h = '';
if (k === 's') {
h += `<h2>${esc(o.name)}</h2>` + txtF('Name', 'name', o.name) + selF('Type', 'kind', { house: 'House', garage: 'Garage / shop' }, o.kind);
h += `<div class="row">${lenF('From left lot line', 'x', o.x)}${lenF('From street', 'y', o.y)}</div>`;
const others = p.structures.filter(x => x !== o);
if (others.length) {
const sides = { right: 'right side', left: 'left side', back: 'back', front: 'front' };
h += `<label>Attach to (shares one wall, no double wall)<select data-attach><option value="">Choose a building and side...</option>${others.map(x =>
Object.entries(sides).map(([k, v]) => `<option value="${x.id}:${k}">${esc(v[0].toUpperCase() + v.slice(1))} of ${esc(x.name)}</option>`).join('')).join('')}</select></label>`;
}
h += `<div class="row">${lenF('Width', 'w', o.w)}${lenF('Depth', 'd', o.d)}</div>`;
h += `<div class="row">${numF('Floors', 'floors', o.floors, 1, 3)}${lenF('Ceiling height', 'wallH', o.wallH)}</div>`;
h += `<h2>Foundation</h2>` + selF('Type', 'foundation', FOUNDATIONS, o.foundation) + selF('Walls', 'fwall', FOUNDATION_WALL, o.fwall);
if (o.foundation === 'basement' || o.foundation === 'walkout') h += lenF('Basement ceiling height', 'basementH', o.basementH);
h += `<label class="row"><input type="checkbox" data-chk="ffeAuto" ${o.ffeAuto ? 'checked' : ''}> Set floor height from the ground automatically</label>`;
h += lenF('Finished floor elevation', 'ffe', o.ffe, '(above the lowest ground)');
h += `<p class="small muted">Ground at the front middle: ${fmtLen(groundAt(p.site, o.x + o.w / 2, o.y))}. Floor is ${fmtLen(o.ffe - groundAt(p.site, o.x + o.w / 2, o.y))} above it.</p>`;
h += `<h2>Roof</h2>` + selF('Shape', 'roof.type', ROOF_TYPES, o.roof.type);
h += `<div class="row">${numF('Pitch (rise per 12")', 'roof.pitch', o.roof.pitch, 1, 18, 0.5)}${lenF('Overhang', 'roof.overhang', o.roof.overhang)}</div>`;
h += selF('Ridge runs', 'roof.ridge', { auto: 'Along the long side', x: 'Left to right', y: 'Front to back' }, o.roof.ridge);
h += selF('Roofing', 'roof.mat', ROOFING, o.roof.mat) + colF('Roof color', 'roof.color', o.roof.color);
h += `<h2>Outside finish</h2>` + selF('Siding', 'ext.mat', SIDING, o.ext.mat) + colF('Siding / brick color', 'ext.color', o.ext.color);
h += lenF('Wainscot height (0 = none)', 'ext.wainscot', o.ext.wainscot) + selF('Wainscot material', 'ext.wainscotMat', { stone: 'Stone veneer', brick: 'Brick' }, o.ext.wainscotMat);
h += colF('Trim color', 'ext.trim', o.ext.trim) + colF(o.kind === 'garage' ? 'Door color' : 'Front door color', 'ext.door', o.ext.door);
const ed = extDetails(o);
h += `<h2>Brick pattern</h2>` + selF('Brick bond (brick siding or brick wainscot)', 'ext.bond', BONDS, ed.bond);
h += selF('Accent band', 'ext.accent', ACCENTS, ed.accent) + (ed.accent !== 'none' ? lenF('Accent band height', 'ext.accentH', ed.accentH) : '');
h += `<h2>Roof edge</h2>` + selF('Soffit (under the overhang)', 'ext.soffit', SOFFITS, ed.soffit);
if (ed.soffit !== 'none') h += colF('Soffit color', 'ext.soffitColor', ed.soffitColor, SW.siding);
h += lenF('Fascia board height (0 = none)', 'ext.fascia', ed.fascia) + colF('Fascia color', 'ext.fasciaColor', ed.fasciaColor, SW.siding);
h += selF('Gutters', 'ext.gutter', GUTTERS, ed.gutter);
if (ed.gutter !== 'none') h += colF('Gutter and downspout color', 'ext.gutterColor', ed.gutterColor, SW.siding);
h += `<h2>Inside</h2>` + selF('Main floor finish', 'floor', FLOORING, o.floor) + colF('Wall paint', 'wallColor', o.wallColor);
h += `<p class="row gap"><button data-act="dup">Duplicate</button><button data-act="del">Delete building</button></p>`;
} else if (k === 'room') {
const s = byId(p.structures, o.s);
h += `<h2>Room</h2>` + txtF('Name', 'name', o.name) + selF('Floor finish', 'floor', FLOORING, o.floor);
h += `<p class="small muted">${Math.round(sqft(o.w * o.d))} square feet. Position is from the ${esc(s?.name)}'s front-left outside corner.</p>`;
h += `<div class="row">${lenF('From left', 'x', o.x)}${lenF('From front', 'y', o.y)}</div><div class="row">${lenF('Width', 'w', o.w)}${lenF('Depth', 'd', o.d)}</div>`;
h += `<h2>Walls and ceiling</h2>` + selF('Wallpaper', 'wallpaper', WALLPAPER, o.wallpaper || 'none');
if (o.wallpaper && o.wallpaper !== 'none') h += colF('Wallpaper color', 'wpColor', o.wpColor || '#d9cfbf', SW.paint);
h += selF('Wainscot (lower wall paneling)', 'wainscot', WAINSCOT_IN, o.wainscot || 'none');
if (o.wainscot && o.wainscot !== 'none') h += lenF('Wainscot height', 'wainH', o.wainH || 36) + colF('Wainscot color', 'wainColor', o.wainColor || '#f4f2ee', SW.paint);
h += selF('Tile backsplash (behind counters, sinks, ranges and vanities)', 'backsplash', BACKSPLASH, o.backsplash || 'none');
if (o.backsplash && o.backsplash !== 'none') h += colF('Tile color', 'bsColor', o.bsColor || '#f2f0ea', SW.paint);
h += selF('Ceiling', 'ceiling', CEILINGS, o.ceiling || 'flat') + colF('Ceiling color', 'ceilColor', o.ceilColor || '#f4f2ee', SW.paint);
if (o.ceiling === 'vaulted' && s && !vaultOk(s, o)) h += `<p class="small bad">Vaulted ceilings need the room to be on the top floor; it shows flat here.</p>`;
const fin = s ? finishText(s, o) : '';
if (fin) h += `<p class="small muted">${esc(fin[0].toUpperCase() + fin.slice(1))}.</p>`;
h += `<p><button data-act="del">Delete room</button></p>`;
} else if (k === 'wall') {
h += `<h2>Inside wall</h2><p class="small muted">Ends measured from the building's front-left corner.</p>`;
h += `<div class="row">${lenF('Start across', 'a.0', o.a[0])}${lenF('Start up', 'a.1', o.a[1])}</div><div class="row">${lenF('End across', 'b.0', o.b[0])}${lenF('End up', 'b.1', o.b[1])}</div>`;
h += selF('Thickness', 't', { 4.5: '2x4 wall (4 1/2")', 6.5: '2x6 wall (6 1/2")' }, o.t) + colF('Paint (blank = building color)', 'color', o.color || app.project.structures.find(s => s.id === o.s)?.wallColor || '#ece6da', SW.paint);
h += `<p><button data-act="del">Delete wall</button></p>`;
} else if (k === 'op') {
const s = byId(p.structures, o.s);
const walls = { N: 'Back wall', E: 'Right wall', S: 'Front wall', W: 'Left wall' };
p.walls.filter(w => w.s === o.s && w.lvl === o.lvl).forEach((w, i) => { walls[w.id] = `Inside wall ${i + 1}`; });
h += `<h2>${OPENINGS[o.kind].name}</h2>` + selF('Type', 'kind', OPENINGS, o.kind) + selF('On wall', 'wall', walls, o.wall);
h += lenF('Distance from wall start', 'off', o.off, '(left end when facing the wall from outside)');
h += `<div class="row">${lenF('Width', 'w', o.w)}${lenF('Height', 'h', o.h)}</div>` + lenF('Bottom above floor', 'sill', o.sill);
if (o.kind === 'window') h += selF('Style', 'style', { grid: 'Grids (colonial)', single: 'Single hung (one bar)', plain: 'Plain glass' }, o.style);
if (o.kind === 'egress') {
h += selF('Egress window type', 'style', EGRESS_STYLES, EGRESS_STYLES[o.style] ? o.style : 'casement');
const ec = egressCheck(o), ww = s && windowWell(p, s, o);
h += `<p class="small ${ec.ok ? 'ok' : 'bad'}" role="status">${esc(ec.text)}</p>`;
h += lenF('Window well sticks out from the wall', 'wellProj', o.wellProj || 36);
h += `<p class="small muted">${ww ? esc(`Window well ${fmtLen(ww.t1 - ww.t0)} wide, ${fmtLen(ww.proj)} out, ${fmtLen(ww.depth)} deep (6" of gravel below the sill, 3" above the ground). Floor ${ww.area.toFixed(1)} sq ft (9 needed).${ww.ladder ? ' Deeper than 44", so it needs a ladder or steps (shown).' : ''}${ww.notes.length ? ' Problem: ' + ww.notes.join('; ') + '.' : ''}`) : 'The sill is above the ground here, so no window well is needed.'}</p>`;
}
if (o.kind === 'door') {
const styles = { ...DOOR_STYLES };
if (isExtWall(o)) delete styles.barn;
h += selF('Door style', 'style', styles, DOOR_STYLES[o.style] ? o.style : 'panel');
}
if (o.kind === 'garage') h += selF('Garage door style', 'style', GARAGE_STYLES, GARAGE_STYLES[o.style] ? o.style : 'panel');
if (o.kind === 'door' || o.kind === 'garage') {
h += colF(o.kind === 'garage' ? 'Garage door color' : 'Door color', 'color', doorColor(o, s), SW.door);
if (o.color) h += `<p><button data-act="doorcol">Use the building's door color</button></p>`;
}
h += `<p><button data-act="del">Delete</button></p>`;
} else if (k === 'furn') {
const d = FURN[o.type] || {}, ceil = d.mount === 'ceiling';
h += `<h2>${esc(o.name)}</h2>` + txtF('Name', 'name', o.name);
h += `<label>Item<select data-ftype>${furnTypeOptions(o.type)}</select></label>`;
h += `<p class="small muted" aria-live="polite">${esc(furnWhere(o))}</p>`;
h += `<div class="row">${lenF('Centre from left', 'x', o.x)}${lenF('Centre from front', 'y', o.y)}</div>`;
h += `<p class="small muted">Measured from the ${esc(byId(p.structures, o.s)?.name)}'s front-left outside corner.</p>`;
const facing = { 0: 'Front faces the street (south)', 90: 'Front faces right (east)', 180: 'Front faces the back (north)', 270: 'Front faces left (west)' };
if (!facing[o.rot || 0]) facing[o.rot] = `Turned ${o.rot} degrees`;
h += selF(d.light && !d.wall ? 'Turn' : 'Facing', 'rot', facing, o.rot || 0) + numF('Turn, degrees counter-clockwise', 'rot', o.rot || 0, 0, 359, 15);
h += `<div class="row">${lenF('Width', 'w', o.w)}${lenF('Depth', 'd', o.d)}</div>`;
h += ceil ? lenF('Hangs down from the ceiling', 'h', o.h) : `<div class="row">${lenF('Height', 'h', o.h)}${lenF('Bottom above floor', 'z', o.z || 0)}</div>`;
h += colF(d.light ? 'Fixture color' : 'Color', 'color', o.color, SW.siding);
if (d.light) {
h += `<h2>Light</h2><label class="row"><input type="checkbox" data-chk="on" ${o.on !== false ? 'checked' : ''}> Switched on</label>`;
h += numF('Brightness (lumens; a 60 watt bulb is 800)', 'lm', o.lm, 50, 20000, 50) + selF('Light color', 'k', KELVIN, o.k);
}
h += `<p class="row gap"><button data-act="turn">Turn 90 degrees</button><button data-act="wall">Push against nearest wall</button></p>`;
h += `<p class="row gap"><button data-act="dupf">Duplicate</button><button data-act="del">Delete</button></p>`;
h += `<p class="small muted">Keys on the plan: arrows move, R turns 90 degrees, Shift+R turns 15, Delete removes.</p>`;
} else if (k === 'stair') {
const s = byId(p.structures, o.s);
h += `<h2>Stairs</h2><p class="small muted" aria-live="polite">${esc(s ? stairText(o, s) : '')}</p>`;
h += selF('Shape', 'shape', STAIR_SHAPES, o.shape);
if (o.shape !== 'straight') h += selF('Turn', 'turn', STAIR_TURNS, o.turn);
h += selF('Direction', 'dir', STAIR_DIRS, o.dir) + lenF('Width', 'w', o.w);
h += `<div class="row">${lenF('From left', 'x', o.x)}${lenF('From front', 'y', o.y)}</div>`;
h += `<p class="small muted">Front-left corner of the stairs' footprint, from the ${esc(s?.name)}'s front-left outside corner. Risers stay under 7 3/4", treads are 10".</p>`;
h += `<p><button data-act="del">Delete stairs</button></p>`;
} else if (k === 'site') {
h += `<h2>${esc(itemName(o))}</h2><p class="small muted" aria-live="polite">${esc(itemText(o))}</p>`;
if (o.type === 'tree') h += selF('Kind', 'kind', TREES, o.kind) + selF('Size', 'size', TREE_SIZES, o.size) + `<div class="row">${lenF('Height', 'h', o.h)}${lenF('Spread', 'spread', o.spread)}</div>`;
if (o.type === 'septic' || o.type === 'drainfield') h += `<div class="row">${lenF('Width', 'w', o.w)}${lenF('Depth', 'd', o.d)}</div>`;
if (o.type !== 'trench') h += `<div class="row">${lenF('From the left lot line', 'x', o.x)}${lenF('From the street', 'y', o.y)}</div>`;
else {
h += selF('Utility', 'util', UTILITIES, o.util) + lenF('Trench depth', 'depth', o.depth);
o.pts.forEach((q, i) => { h += `<div class="row">${lenF(`Point ${i + 1} from the left lot line`, `pts.${i}.0`, q[0])}${lenF(`Point ${i + 1} from the street`, `pts.${i}.1`, q[1])}</div>`; });
h += `<p class="row gap"><button data-act="addpt">Add a point</button>${o.pts.length > 2 ? '<button data-act="delpt">Remove the last point</button>' : ''}</p>`;
}
if (o.type === 'well' || o.type === 'septic' || o.type === 'drainfield')
for (const c of siteChecks(p)) h += `<p class="small ${c.ok ? 'ok' : 'bad'}">${esc(c.text)}</p>`;
h += `<p><button data-act="del">Delete</button></p>`;
}
el.innerHTML = h;
const ro = app.role === 'view';
for (const i of el.querySelectorAll('input,select,button')) if (ro) i.disabled = true;
for (const i of el.querySelectorAll('[data-len]')) i.onchange = () => {
const v = parseLen(i.value);
if (v == null) { app.say('Could not read that length. Type feet like 12 or 12\' 6", or inches like 30".'); i.value = fmtLen(getPath(o, i.dataset.len)); return; }
setPath(o, i.dataset.len, Math.round(v * 8) / 8);
if (i.dataset.len === 'ffe') o.ffeAuto = false;
if (k === 'op' && (i.dataset.len === 'off' || i.dataset.len === 'w')) clampOpening(o);
app.changed(`${i.previousSibling?.textContent || 'Value'} ${fmtLen(getPath(o, i.dataset.len), { words: true })}`);
};
for (const i of el.querySelectorAll('[data-txt]')) i.onchange = () => { setPath(o, i.dataset.txt, i.value.trim() || 'Untitled'); app.changed('Renamed'); };
for (const i of el.querySelectorAll('[data-num]')) i.onchange = () => {
const v = Math.max(+i.min, Math.min(+i.max, +i.value || +i.min));
setPath(o, i.dataset.num, v);
if (i.dataset.num === 'floors') trimLevels(o);
app.changed(`${(i.closest('label')?.firstChild?.textContent || i.dataset.num).trim()} ${v}`);
};
for (const i of el.querySelectorAll('[data-sel]')) i.onchange = () => {
let v = i.value; if (['t', 'rot', 'k', 'dir'].includes(i.dataset.sel)) v = +v;
setPath(o, i.dataset.sel, v);
if (k === 'op' && i.dataset.sel === 'kind') { const d = OPENINGS[v]; Object.assign(o, { w: d.w, h: d.h, sill: d.sill, style: v === 'window' ? 'grid' : v === 'egress' ? 'casement' : 'panel' }); clampOpening(o); }
if (k === 'site' && (i.dataset.sel === 'kind' || i.dataset.sel === 'size')) { const n = newSiteItem('tree', 0, 0, { kind: o.kind, size: o.size }); o.h = n.h; o.spread = n.spread; }
if (k === 'site' && i.dataset.sel === 'util') o.depth = UTILITIES[v].depth;
if (k === 'op' && i.dataset.sel === 'style' && v === 'double' && o.w < 60) { o.w = 60; clampOpening(o); }
if (k === 'op' && i.dataset.sel === 'wall') clampOpening(o);
if (i.dataset.sel === 'foundation') trimLevels(o);
app.changed(i.options[i.selectedIndex].text);
};
for (const i of el.querySelectorAll('[data-col]')) i.onchange = () => { setPath(o, i.dataset.col, i.value); app.changed('Color changed'); };
for (const i of el.querySelectorAll('[data-swsel]')) i.onchange = () => {
if (!i.value) return;
setPath(o, i.dataset.swsel, i.value);
app.changed(`Color ${i.options[i.selectedIndex].text}`);
};
for (const b of el.querySelectorAll('[data-sw]')) b.onclick = () => { setPath(o, b.dataset.sw, b.dataset.hex); app.changed(`Color ${b.title}`); };
for (const i of el.querySelectorAll('[data-chk]')) i.onchange = () => {
setPath(o, i.dataset.chk, i.checked);
app.changed(i.dataset.chk === 'on' ? (i.checked ? 'Light switched on' : 'Light switched off') : i.checked ? 'Floor height automatic' : 'Floor height manual');
};
for (const b of el.querySelectorAll('[data-act]')) b.onclick = () => {
const a = b.dataset.act;
if (a === 'del') removeSel();
else if (a === 'dup') duplicate(o);
else if (a === 'dupf') dupFurn(o);
else if (a === 'turn') turn(o, 90);
else if (a === 'doorcol') { o.color = ''; app.changed(`Door color now follows ${byId(p.structures, o.s)?.name}`); }
else if (a === 'addpt') { const q = o.pts.at(-1); o.pts.push([q[0] + 10 * FT, q[1]]); app.changed(`Point ${o.pts.length} added 10 feet to the right of the last one. Trench now ${fmtLen(polyLen(o.pts), { words: true })}.`); }
else if (a === 'delpt') { o.pts.pop(); app.changed(`Last point removed. Trench now ${fmtLen(polyLen(o.pts), { words: true })}.`); }
else if (a === 'wall') {
const w = backToWall(p, o, 20 * FT);
app.changed(w ? `${o.name} pushed against the ${w}, facing into the room` : 'No wall nearby');
}
};
const ft = el.querySelector('[data-ftype]');
if (ft) ft.onchange = () => {
const d = FURN[ft.value], old = FURN[o.type], n = newFurniture(ft.value, { id: o.s }, o.lvl, o.x, o.y, o.rot);
if (o.name === old?.name) o.name = n.name;
for (const key of ['type', 'w', 'd', 'h', 'z', 'color', 'lm', 'k', 'on']) o[key] = n[key];
if (!d.light) { delete o.lm; delete o.k; delete o.on; }
app.changed(`Changed to ${d.name}`);
};
const at = el.querySelector('[data-attach]');
if (at) at.onchange = () => {
const [id, side] = at.value.split(':'), other = byId(p.structures, id);
if (!other) return;
attachTo(o, other, side);
app.changed(`${o.name} attached to the ${side} of ${other.name}. The wall they share is drawn once.`);
};
}
function clampOpening(o) {
const w = o.wall.length === 1 ? extWalls(byId(app.project.structures, o.s))[o.wall] : null;
let len = w?.len;
if (!w) { const iw = byId(app.project.walls, o.wall); if (iw) len = Math.hypot(iw.b[0] - iw.a[0], iw.b[1] - iw.a[1]); }
if (len) { o.w = Math.min(o.w, len); o.off = Math.max(0, Math.min(len - o.w, o.off)); }
}
function trimLevels(s) {
// drop rooms/walls/openings on levels this building no longer has
const L = levelsOf(s), p = app.project;
for (const k of ['rooms', 'walls', 'openings', 'furniture']) p[k] = p[k].filter(o => o.s !== s.id || L.includes(o.lvl));
p.stairs = p.stairs.filter(st => st.s !== s.id || stairOk(s, st));
}
function removeSel() {
const o = app.sel, k = app.selKind, p = app.project;
if (!o || app.role === 'view') return;
if (k === 's') {
if (!confirm(`Delete ${o.name} and everything inside it?`)) return;
p.structures = p.structures.filter(x => x !== o);
for (const key of ['rooms', 'walls', 'openings', 'furniture', 'stairs']) p[key] = p[key].filter(x => x.s !== o.id);
} else if (k === 'room') p.rooms = p.rooms.filter(x => x !== o);
else if (k === 'stair') p.stairs = p.stairs.filter(x => x !== o);
else if (k === 'site') p.site.items = p.site.items.filter(x => x !== o);
else if (k === 'furn') p.furniture = p.furniture.filter(x => x !== o);
else if (k === 'wall') { p.walls = p.walls.filter(x => x !== o); p.openings = p.openings.filter(x => x.wall !== o.id); }
else if (k === 'op') p.openings = p.openings.filter(x => x !== o);
app.sel = null;
app.changed('Deleted');
}
function duplicate(s) {
const p = app.project, c = JSON.parse(JSON.stringify(s));
c.id = uid(); c.name = s.name + ' copy'; c.x = s.x + s.w + 10 * FT;
p.structures.push(c);
const map = {};
for (const w of p.walls.filter(w => w.s === s.id)) { const n = { ...w, id: uid(), s: c.id, a: [...w.a], b: [...w.b] }; map[w.id] = n.id; p.walls.push(n); }
for (const r of p.rooms.filter(r => r.s === s.id)) p.rooms.push({ ...r, id: uid(), s: c.id });
for (const o of p.openings.filter(o => o.s === s.id)) p.openings.push({ ...o, id: uid(), s: c.id, wall: map[o.wall] || o.wall });
for (const f of p.furniture.filter(f => f.s === s.id)) p.furniture.push({ ...f, id: uid(), s: c.id });
for (const st of p.stairs.filter(st => st.s === s.id)) p.stairs.push({ ...st, id: uid(), s: c.id });
app.select(c, 's'); app.changed(`${c.name} added`);
}
// ------------------------------------------------------------ 3D
let build3dT = null;
async function build3d() {
if (!app.view3d) {
try {
const { View3D } = await import('./view3d.js');
$('#threeMsg')?.remove();
app.view3d = new View3D($('#threeHost'), app);
} catch (e) { $('#threeMsg').textContent = '3D could not load: ' + e.message; return; }
}
const v = app.view3d;
v.opts.roof = $('#showRoof').checked; v.opts.terrain = $('#showGround').checked; v.opts.maxLevel = +$('#maxLevel').value;
v.opts.furniture = $('#showFurn').checked;
const night = $('#dayNight').value === 'night';
if (v.night !== night) v.setNight(night);
v.lightsOn = $('#lightsOn').checked;
clearTimeout(build3dT);
build3dT = setTimeout(() => { v.resize(); v.build(); }, 60);
}
$('#hour').oninput = (e) => {
const h = +e.target.value, hh = Math.floor(h), mm = Math.round((h - hh) * 60);
$('#hourOut').textContent = `${(hh + 11) % 12 + 1}:${String(mm).padStart(2, '0')} ${hh < 12 ? 'am' : 'pm'}`;
app.view3d?.setHour(h);
};
for (const id of ['#showRoof', '#showGround', '#maxLevel', '#showFurn']) $(id).onchange = () => build3d();
$('#dayNight').onchange = () => {
const night = $('#dayNight').value === 'night';
$('#lightsOn').checked = night;
$('#hour').disabled = night;
const v = app.view3d;
if (v) { v.setNight(night); v.setLights(night); }
const n = app.project.furniture.filter(f => FURN[f.type]?.light && f.on !== false).length;
app.say(night ? `Night. ${n} light${n === 1 ? '' : 's'} switched on.` : 'Day. Lights off.');
};
$('#lightsOn').onchange = () => { app.view3d?.setLights($('#lightsOn').checked); app.say($('#lightsOn').checked ? 'Lights on' : 'Lights off'); };
$('#viewHome').onclick = () => app.view3d?.home();
$('#walkBtn').onclick = () => {
const v = app.view3d; if (!v) return;
if (v.walk) { v.stopWalk(); $('#walkBtn').textContent = 'Walk through'; app.say('Walk mode off'); return; }
const lv = $('#walkLevel').value, s = app.project.structures[0];
let z = designAt(app.project, app.project.site.w / 2, 0);
if (lv !== 'out' && s) {
z = levelBase(s, +lv);
// every floor and the roof stay up, so the stairs lead somewhere and the rooms have ceilings
$('#maxLevel').value = '99'; v.opts.maxLevel = 99; v.opts.roof = true; $('#showRoof').checked = true; v.build();
}
v.startWalk(z);
if (lv === 'out') v.walk.pos.set(app.project.site.w / 2, z + 66, 12 * FT); // standing in the street, facing the lot
$('#walkBtn').textContent = 'Stop walking';
app.say('Walk mode. Use W A S D or arrow keys, Q and E to turn.');
};
$('#shot').onclick = () => {
const v = app.view3d; if (!v) return;
v.renderer.render(v.scene, v.camera);
const a = document.createElement('a');
a.href = v.renderer.domElement.toDataURL('image/png');
a.download = (app.project.name || 'design').replace(/[^\w-]+/g, '_') + '-3d.png';
a.click();
};
$('#photoBtn').onclick = () => {
const v = app.view3d; if (!v) return;
const dlg = $('#photoDlg'), w = +$('#photoSize').value;
$('#photoMsg').textContent = 'Rendering...';
$('#photoImg').removeAttribute('src');
dlg.showModal();
setTimeout(() => {
try {
const url = v.photo(w);
$('#photoImg').src = url;
$('#photoImg').alt = `Photo render of ${app.project.name}, ${w} pixels wide. ${describe(app.project)}`;
$('#photoDl').href = url;
$('#photoDl').download = (app.project.name || 'design').replace(/[^\w-]+/g, '_') + '-photo.jpg';
$('#photoMsg').textContent = `Done: ${w} pixels wide, drawn at up to twice that size and scaled down for smooth edges, with sharper sun shadows.`;
app.say('Photo render ready. Download picture is the next button.');
$('#photoDl').focus();
} catch (e) { $('#photoMsg').textContent = 'Could not render: ' + e.message; }
}, 50);
};
$('#photoClose').onclick = () => $('#photoDlg').close();
// ------------------------------------------------------------ elevations
$('#elevSide').innerHTML = opt(SIDES, 'front');
$('#elevSide').onchange = () => renderElev();
function renderElev() {
const svg = $('#elevSvg'), r = svg.getBoundingClientRect();
if (!r.width) return;
const side = $('#elevSide').value;
let e = drawElevation(app.project, side, { u: 1 });
const b = e.bounds, u = Math.max((b.x1 - b.x0) / r.width, (b.y1 - b.y0) / r.height) * 1.05;
e = drawElevation(app.project, side, { u });
const w = r.width * u, h = r.height * u, cx = (b.x0 + b.x1) / 2, cy = -(b.y0 + b.y1) / 2;
svg.setAttribute('viewBox', `${cx - w / 2} ${cy - h / 2} ${w} ${h}`);
svg.innerHTML = e.svg;
svg.setAttribute('aria-label', `${SIDES[side]} elevation drawing. ${describe(app.project)}`);
}
// ------------------------------------------------------------ site + grading
function renderSite() {
const p = app.project, f = $('#lotForm');
if (!f.contains(document.activeElement)) {
f.w.value = fmtLen(p.site.w); f.d.value = fmtLen(p.site.d);
f.front.value = fmtLen(p.site.setback.front); f.side.value = fmtLen(p.site.setback.side); f.rear.value = fmtLen(p.site.setback.rear);
f.drive.checked = !!p.site.drive;
f.arrows.checked = p.site.arrows !== false;
}
const I = p.site.items, checks = siteChecks(p);
let si = I.length ? `<ul>${I.map(it => `<li>${esc(itemText(it))}</li>`).join('')}</ul>` : '<p class="muted small">Nothing yet. On the Floor plans tab, pick Site plan in the Showing list, then use Lot items or the Tree and Trench tools.</p>';
for (const c of checks) si += `<p class="small ${c.ok ? 'ok' : 'bad'}">${esc(c.text)}</p>`;
$('#siteItemsOut').innerHTML = si;
const g = $('#groundForm');
if (!g.contains(document.activeElement)) {
const S = p.site, ft = (z) => (z / 12).toFixed(1);
g.fl.value = ft(groundAt(S, 0, 0)); g.fr.value = ft(groundAt(S, S.w, 0)); g.bl.value = ft(groundAt(S, 0, S.d)); g.br.value = ft(groundAt(S, S.w, S.d));
g.address.value = S.address || '';
}
const ew = earthwork(p), r1 = (v) => v.toFixed(1);
const zs = p.site.grid.z, fall = (Math.max(...zs) - Math.min(...zs)) / 12;
let h = `<p>Ground rises or falls about <b>${fall.toFixed(1)} feet</b> across the lot.</p>`;
h += `<table><tr><th>Building</th><th>Foundation</th><th class="n">Floor above ground</th></tr>`;
for (const s of p.structures) {
const lo = Math.min(...[[s.x, s.y], [s.x + s.w, s.y], [s.x, s.y + s.d], [s.x + s.w, s.y + s.d]].map(([x, y]) => groundAt(p.site, x, y)));
const hi = Math.max(...[[s.x, s.y], [s.x + s.w, s.y], [s.x, s.y + s.d], [s.x + s.w, s.y + s.d]].map(([x, y]) => groundAt(p.site, x, y)));
h += `<tr><td>${esc(s.name)}</td><td>${FOUNDATIONS[s.foundation]}</td><td class="n">${fmtLen(s.ffe - hi)} to ${fmtLen(s.ffe - lo)}</td></tr>`;
if (hi - lo > 48 && s.foundation !== 'walkout') h += `<tr><td colspan="3" class="small">Ground drops ${fmtLen(hi - lo)} under ${esc(s.name)}. A walkout basement could fit this slope.</td></tr>`;
}
h += `</table><h3>Dirt work</h3><table>`;
h += `<tr><td>Cut (dig away) for building pads</td><td class="n">${r1(ew.cut)} cubic yards</td></tr>`;
h += `<tr><td>Fill (bring in) for building pads</td><td class="n">${r1(ew.fill)} cubic yards</td></tr>`;
for (const b of ew.basements) h += `<tr><td>Basement dig for ${esc(b.name)} (${fmtLen(b.depth)} deep, 2' over-dig)</td><td class="n">${r1(b.yd3)} cubic yards</td></tr>`;
const net = ew.cut + ew.dig - ew.fill;
h += `<tr><td><b>${net >= 0 ? 'Extra dirt to haul off or spread' : 'Dirt to bring in'}</b></td><td class="n"><b>${r1(Math.abs(net))} cubic yards</b></td></tr>`;
h += `<tr><td>About ${Math.ceil(Math.abs(net) / 12)} dump-truck loads (12 yd each)</td><td></td></tr>`;
h += `<tr><td>Area disturbed (clear and regrade)</td><td class="n">${Math.round(ew.disturbedSqft).toLocaleString()} sq ft</td></tr>`;
const gravel = p.structures.filter(s => s.foundation !== 'crawl').reduce((a, s) => a + s.w * s.d * 4, 0) / 46656;
h += `<tr><td>Gravel base under slabs (4")</td><td class="n">${r1(gravel)} cubic yards</td></tr></table>`;
h += `<p class="small muted">The site plan shows contour lines every foot of finished ground (heavy line every 5 feet). Grading blends from each building pad back to natural ground over 10 feet.</p>`;
$('#gradeOut').innerHTML = h;
}
for (const n of ['w', 'd', 'front', 'side', 'rear']) $('#lotForm')[n].onchange = (e) => {
const v = parseLen(e.target.value), S = app.project.site;
if (v == null || v < 0) { app.say('Could not read that length.'); return; }
if (n === 'w' || n === 'd') S[n] = Math.max(20 * FT, v); else S.setback[n] = v;
app.changed(`Lot ${n === 'w' ? 'width' : n === 'd' ? 'depth' : n + ' setback'} ${fmtLen(v, { words: true })}`);
};
$('#lotForm').drive.onchange = (e) => { app.project.site.drive = e.target.checked; app.changed(e.target.checked ? 'Driveway on' : 'Driveway off'); };
$('#lotForm').arrows.onchange = (e) => { app.project.site.arrows = e.target.checked; app.changed(e.target.checked ? 'Slope arrows on' : 'Slope arrows off'); };
$('#setCorners').onclick = () => {
const g = $('#groundForm'), v = ['fl', 'fr', 'bl', 'br'].map(k => parseFloat(g[k].value));
if (v.some(isNaN)) { app.say('Type a number of feet in all four corners.'); return; }
const lo = Math.min(...v);
app.project.site.grid = { n: 2, z: v.map(x => Math.round((x - lo) * 12)) };
app.changed('Corner heights set');
};
$('#lookup').onclick = async () => {
const g = $('#groundForm'), st = $('#lookupStatus'), S = app.project.site;
st.textContent = 'Finding the address...';
try {
const geo = await api('GET', '/api/geocode?address=' + encodeURIComponent(g.address.value));
st.textContent = `Found ${geo.match}. Getting 81 ground heights from USGS (about 20 seconds)...`;
const n = 9;
const r = await api('POST', '/api/elevation-grid', { lat: geo.lat, lon: geo.lon, width_ft: S.w / 12, depth_ft: S.d / 12, n });
const lo = Math.min(...r.feet);
S.grid = { n: r.n, z: r.feet.map(f => Math.round((f - lo) * 12)) };
S.address = geo.match; S.lat = geo.lat; S.lon = geo.lon; S.baseElevFt = lo;
st.textContent = `Done. Lowest point is ${lo.toFixed(0)} feet above sea level; the lot rises ${((Math.max(...r.feet) - lo)).toFixed(1)} feet. Data is about 1 to 10 meter resolution, so check it on site.`;
app.changed('Ground heights loaded from USGS');
} catch (e) { st.textContent = e.message; }
};
// ------------------------------------------------------------ materials
function renderMaterials() {
const L = materials(app.project), r0 = (v) => Math.round(v).toLocaleString();
let h = '';
for (const m of L) {
h += `<h3>${esc(m.name)}</h3><table>`;
h += `<tr><td>${esc(m.sidingName)} (walls less doors and windows)</td><td class="n">${r0(m.sidingSqft)} sq ft</td></tr>`;
if (m.bricks) h += `<tr><td>Bricks (6.75 per sq ft + 5%)</td><td class="n">${r0(m.bricks)}</td></tr>`;
if (m.wainscotSqft) h += `<tr><td>Wainscot</td><td class="n">${r0(m.wainscotSqft)} sq ft</td></tr>`;
h += `<tr><td>${esc(m.roofName)}</td><td class="n">${r0(m.roofSqft)} sq ft (${m.roofSquares.toFixed(1)} squares)</td></tr>`;
h += `<tr><td>Concrete: footings, walls, slab</td><td class="n">${m.concreteYd3.toFixed(1)} cubic yards</td></tr>`;
for (const [f, a] of Object.entries(m.floors)) h += `<tr><td>${esc(FLOORING[f] || f)}</td><td class="n">${r0(a)} sq ft</td></tr>`;
for (const [d, n] of Object.entries(m.doors)) h += `<tr><td>${esc(d)}</td><td class="n">${n}</td></tr>`;
const row = (t, v) => { if (v) h += `<tr><td>${esc(t)}</td><td class="n">${v}</td></tr>`; };
row('Windows', m.windows); row('Sliding glass doors', m.sliders);
row('Egress windows', m.egress); row('Window wells (galvanized, with gravel)', m.wells);
row(`${m.soffitName || 'Soffit'}`, m.soffitSqft ? `${r0(m.soffitSqft)} sq ft` : 0);
row('Fascia and rake trim', `${r0(m.fasciaFt)} ft`);
row(m.gutterName, m.gutterFt ? `${r0(m.gutterFt)} ft` : 0); row('Downspouts', m.downspouts);
row('Stairs', m.stairs);
const s = app.project.structures[L.indexOf(m)], F = s ? finishAreas(app.project, s) : null;
if (F) {
for (const [k, a] of Object.entries(F.wallpaperBy)) row(`Wallpaper, ${WALLPAPER[k].toLowerCase()} (about ${Math.ceil(a / 25)} single rolls)`, `${r0(a)} sq ft`);
for (const [k, a] of Object.entries(F.wainscotBy)) row(`Inside wainscot, ${WAINSCOT_IN[k].toLowerCase()}`, `${r0(a)} sq ft`);
for (const [k, a] of Object.entries(F.backsplashBy)) row(`Backsplash, ${BACKSPLASH[k].toLowerCase()}`, `${r0(a)} sq ft`);
row('Tray ceilings', F.tray); row('Coffered ceiling', F.coffered ? `${r0(F.coffered)} sq ft` : 0); row('Vaulted ceiling', F.vaulted ? `${r0(F.vaulted)} sq ft` : 0);
}
h += `</table>`;
}
const I = app.project.site.items;
if (I.length) {
h += `<h3>Site</h3><table>`;
for (const u of Object.keys(UTILITIES)) { const t = I.filter(i => i.type === 'trench' && i.util === u); if (t.length) h += `<tr><td>${esc(UTILITIES[u].name)} trench</td><td class="n">${r0(t.reduce((a, x) => a + polyLen(x.pts), 0) / 12)} ft</td></tr>`; }
for (const [k, n] of Object.entries(SITE_ITEM_TYPES)) { const c = I.filter(i => i.type === k && k !== 'trench').length; if (c) h += `<tr><td>${esc(n)}${c > 1 ? 's' : ''}</td><td class="n">${c}</td></tr>`; }
h += `</table>`;
}
$('#matOut').innerHTML = h || '<p>No buildings yet.</p>';
renderCost();
}
// ------------------------------------------------------------ cost estimate
const money = (v) => '$' + Math.round(v).toLocaleString();
function renderCost() {
const E = estimate(app.project), q = (v) => (Math.round(v * 10) / 10).toLocaleString();
let h = '';
for (const b of [...E.buildings, E.site]) {
h += `<h3>${esc(b.name)}: ${money(b.total)}${b.sqft ? ` (${money(b.perSqft)} per sq ft of ${Math.round(b.sqft).toLocaleString()} sq ft)` : ''}</h3><table class="cost"><tr><th>Item</th><th class="n">Amount</th><th class="n">Price</th><th class="n">Cost</th></tr>`;
for (const l of b.lines) h += `<tr><td>${esc(l.label)}</td><td class="n">${q(l.qty)} ${esc(l.unit)}</td><td class="n">${money(l.price)}</td><td class="n">${money(l.cost)}</td></tr>`;
h += `</table>`;
}
h += `<table class="cost"><tr><td>Subtotal</td><td class="n">${money(E.subtotal)}</td></tr><tr><td>Contingency ${E.contPct}%</td><td class="n">${money(E.contingency)}</td></tr>`;
h += `<tr><td><b>Rough total</b></td><td class="n"><b>${money(E.total)}</b></td></tr>${E.sqft ? `<tr><td>Per square foot of floor (all buildings)</td><td class="n">${money(E.total / E.sqft)}</td></tr>` : ''}</table>`;
$('#costOut').innerHTML = h;
const po = $('#priceOut');
if (po.contains(document.activeElement)) return;
let ph = '';
for (const g of PRICE_GROUPS) {
ph += `<h4>${esc(g)}</h4><table class="prices">`;
for (const [k, [grp, label, unit]] of Object.entries(PRICES)) if (grp === g)
ph += `<tr><td><label for="pr-${k}">${esc(label)}, ${unit === 'percent' ? 'percent' : 'dollars per ' + esc(unit)}</label></td><td class="n"><input id="pr-${k}" data-price="${k}" type="number" min="0" step="any" value="${price(app.project, k)}"${app.project.prices[k] != null ? ' class="changed"' : ''}></td></tr>`;
ph += `</table>`;
}
po.innerHTML = ph;
for (const i of po.querySelectorAll('[data-price]')) {
if (app.role === 'view') i.disabled = true;
i.onchange = () => {
const v = parseFloat(i.value), k = i.dataset.price;
if (isNaN(v) || v < 0) { i.value = price(app.project, k); app.say('Type a number.'); return; }
app.project.prices[k] = v;
app.changed(`${PRICES[k][1]} now ${PRICES[k][2] === 'percent' ? v + ' percent' : money(v)}. Total ${money(estimate(app.project).total)}.`);
};
}
}
$('#resetPrices').onclick = () => {
if (!Object.keys(app.project.prices).length) { app.say('Prices are already the typical ones.'); return; }
app.project.prices = {};
$('#priceOut').innerHTML = '';
app.changed(`Prices reset. Total ${money(estimate(app.project).total)}.`);
};
// ------------------------------------------------------------ print
$('#printScale').innerHTML = opt(SCALES, '48');
$('#printPaper').innerHTML = Object.entries(PAPERS).map(([k, v]) => `<option value="${k}">${v.name}</option>`).join('');
$('#printScale').onchange = $('#printPaper').onchange = () => renderPrintTab();
function renderPrintTab() {
const p = app.project, sc = +$('#printScale').value, pp = $('#printPaper').value;
const prev = new Set([...document.querySelectorAll('#sheetList input:checked')].map(i => i.value));
const first = !document.querySelector('#sheetList input');
$('#sheetList').innerHTML = sheetList(p).map(s => `<label><input type="checkbox" value="${s.id}" ${first || prev.has(s.id) ? 'checked' : ''}> ${esc(s.name)} <span class="small muted">- ${esc(fitsText(p, s.id, sc, pp))}</span></label>`).join('');
}
$('#doPrint').onclick = () => {
const ids = [...document.querySelectorAll('#sheetList input:checked')].map(i => i.value);
if (!ids.length) { app.say('Pick at least one sheet.'); return; }
setPlanOptions({ furn: $('#printFurn').checked, lights: $('#printLights').checked });
const n = buildPrint(app.project, ids, +$('#printScale').value, $('#printPaper').value);
app.say(`Printing ${n} page${n > 1 ? 's' : ''}`);
setTimeout(() => window.print(), 100);
};
// ------------------------------------------------------------ projects dialog
const dlg = $('#projDlg');
$('#btnProjects').onclick = async () => {
await renderProjects();
dlg.showModal();
};
$('#closeDlg').onclick = () => dlg.close();
async function renderProjects() {
let list = [];
try { list = (await api('GET', '/api/projects')).projects; } catch (e) { $('#projList').innerHTML = `<li>${esc(e.message)}</li>`; return; }
$('#projList').innerHTML = list.map(p => `<li class="row gap"><button data-open="${p.id}" aria-current="${p.id === app.id}">${esc(p.name)}</button>
<span class="small muted">${p.role === 'owner' ? 'yours' : 'shared by ' + esc(p.owner_name) + ' (' + p.role + ')'} · ${new Date(p.updated * 1000).toLocaleDateString()}</span>
${p.role === 'owner' ? `<button data-del="${p.id}" aria-label="Delete ${esc(p.name)}">Delete</button>` : ''}</li>`).join('') || '<li class="muted">No saved projects.</li>';
for (const b of $('#projList').querySelectorAll('[data-open]')) b.onclick = async () => { if (app.dirty) await save(); await openProject(+b.dataset.open); dlg.close(); };
for (const b of $('#projList').querySelectorAll('[data-del]')) b.onclick = async () => {
if (!confirm('Delete this project for good? This cannot be undone.')) return;
await api('DELETE', `/api/projects/${b.dataset.del}`);
if (+b.dataset.del === app.id) { app.id = null; app.dirty = true; setStatus('Deleted from server; Save to keep this copy'); }
renderProjects();
};
const sb = $('#shareBox');
if (app.id && app.role === 'owner') {
const r = await api('GET', `/api/projects/${app.id}`).catch(() => null);
const shares = r?.shares || [];
sb.innerHTML = `<h3>Share "${esc(app.project.name)}"</h3>
<p class="small muted">Type the username of a family member or friend who has an account.</p>
<div class="row gap"><input id="shareUser" aria-label="Username"><select id="shareRole" aria-label="Access"><option value="view">Can view</option><option value="edit">Can edit</option><option value="remove">Remove access</option></select><button id="shareGo">Share</button></div>
<p class="small">${shares.length ? 'Shared with: ' + shares.map(s => `${esc(s.username)} (${s.role})`).join(', ') : 'Not shared yet.'}</p><p id="shareMsg" class="small" role="status"></p>`;
$('#shareGo').onclick = async () => {
try { await api('POST', `/api/projects/${app.id}/share`, { username: $('#shareUser').value, role: $('#shareRole').value }); renderProjects(); app.say('Sharing updated'); }
catch (e) { $('#shareMsg').textContent = e.message; }
};
} else sb.innerHTML = '';
}
$('#newProj').onclick = async () => {
if (app.dirty) await save();
loadProject(starter($('#newKind').value));
await save();
dlg.close();
app.say('New project created');
};
initFurnUI();
initPhase3UI();
startup();
EOF
3. Pack the app
Proxmox host shell. This puts the files in the layout the app expects (server files at the top, page files under public/):
mkdir -p pkg/public/js
cp app.py pages.py build.service pkg/
cp index.html style.css pkg/public/
cp model.js plan2d.js textures.js view3d.js elev.js print.js furniture.js furniture3d.js stairs.js rooms.js siteitems.js finishes.js estimate.js details3d.js starter2.js main.js pkg/public/js/
tar -cf app.tar -C pkg .
4. Create the container
Proxmox host shell. Takes about 3 minutes (most of it is the package upgrade), and prints the new container's IP at the end.
bash 01-create-ct.sh 310
5. Install the app
Proxmox host shell. Takes about 15 seconds:
bash 02-deploy-app.sh
It should end with ok <- Build Designer healthy.
6. Add the site to the edge Caddy (CT 108)
Proxmox host shell:
pct push 108 edge-add.sh /root/edge-add-build.sh
This passes CT 310's current IP to the script:
pct exec 108 -- bash /root/edge-add-build.sh $(pct exec 310 -- hostname -I | awk '{print $1}')
It should print Valid configuration and EDGE-OK.
7. Turn on the BlockCraft sign-in
Proxmox host shell. Skip this if you don't run BlockCraft (CT 127).
bash install-bc-sync.sh
It should end with a line showing /opt/build/data/blockcraft.json owned by buildapp with -rw-------.
8. Add the Uptime Kuma monitors
Proxmox host shell. Copy the script into CT 202 (creator-signup), which already has the Uptime Kuma login:
pct push 202 /root/build/add-kuma-monitors.py /root/build-kuma.py
Run it, passing CT 310's current IP. It skips monitors that already exist:
pct exec 202 -- /opt/creator-signup/venv/bin/python /root/build-kuma.py $(pct exec 310 -- hostname -I | awk '{print $1}')
It should print added: twice (or exists: if they were already there).
9. Sign in
Open https://build.503mail.com and sign in with a BlockCraft name and the family code. Caddy gets the certificate on the first visit, so the very first load can take a few seconds. A new user starts with a sample 40 by 28 foot ranch house and an attached 2-car garage, already furnished and lit.
For someone without BlockCraft, make an invite in the Proxmox host shell and send them the link it prints:
bash make-invite.sh
Updating the app later
Edit the files in /root/build, then repeat step 3 (pack) and step 5 (install). After a page-only change, users just reload the page.
To try a change on the test copy first, pack it as app-test.tar instead and run the test deploy (Proxmox host shell):
tar -cf app-test.tar -C pkg .
bash 03-deploy-test.sh
The test copy listens on CT 310 port 8001 over plain HTTP. From a PC, reach it through an SSH tunnel (ssh -N -L 8765:<CT 310 IP>:8001 root@<proxmox host>), then open http://localhost:8765.
Troubleshooting: where the logs are
The app (gunicorn)
- Where: systemd journal for
build, in CT 310 (build-designer) - Open: Proxmox host shell
pct exec 310 -- journalctl -u build -n 50 --no-pager
- Look for:
Booting worker with pidis a healthy start.Tracebackis a real error, and the last lines say where.[CRITICAL] WORKER TIMEOUTmeans a request took longer than 120 seconds (usually a slow USGS lookup). The app logs no normal requests.
Health
pct exec 310 -- curl -fsS http://127.0.0.1:8000/healthz
- Look for:
ok. A connection error means the service is down:pct exec 310 -- systemctl restart build.
BlockCraft sign-in sync
- Where: systemd journal for
build-bc-syncon the Proxmox host
journalctl -u build-bc-sync -n 20 --no-pager
- Look for:
Finished build-bc-sync.serviceevery 5 minutes.BlockCraft login list copied to CT 310only appears when the list or a code changed.pct pullerrors mean CT 127 or CT 310 is stopped. On the sign-in page, a right BlockCraft name and code that still fails usually means the copy hasn't run since the code changed: wait 5 minutes, or runsystemctl start build-bc-syncon the host.
Edge Caddy (CT 108)
pct exec 108 -- journalctl -u caddy -n 100 --no-pager
- Look for:
certificate obtained successfullyfor build.503mail.com.dial tcp ... :8000: connect: no route to hostmeans CT 310 is stopped or its IP changed (DHCP). If the IP changed, fix thereverse_proxyline in/etc/caddy/Caddyfileinside CT 108, thenpct exec 108 -- systemctl reload caddy. The warningCaddyfile input is not formattedis harmless.
Users and projects
List the users (Proxmox host shell):
pct exec 310 -- runuser -u buildapp -- env BUILD_DATA=/opt/build/data python3 /opt/build/app/app.py users
Set a new password for a local (non-BlockCraft) user. Replace NAME, then type the password when asked:
pct exec 310 -- runuser -u buildapp -- env BUILD_DATA=/opt/build/data python3 /opt/build/app/app.py reset-password NAME
In the browser
- "3D could not load": the page couldn't reach the jsDelivr CDN for Three.js, or the browser has WebGL turned off.
- "Signed out. Reload the page and sign in.": the 30-day sign-in ran out. Reload, and the unsaved change is saved after signing in.
- Address not found: the Census geocoder only knows US street addresses. Type the four corner heights by hand instead.
Quick health check
pct exec 310 -- systemctl is-active build
Notes and gotchas from the build
- Uptime Kuma: monitor #51 "build.503mail.com — Build Designer" checks
/loginfor the words "Build Designer", and monitor #52 checkshttp://<CT 310 IP>:8000/healthzfor "ok". Both are on https://status.503mail.com. Red on #51 only means Caddy or DNS; red on both means the app is down. - Backups: CT 310 is in the creator-nightly vzdump job (2:00 am, keeps the last 7, saved to the PC share). The first backup was 267 MB.
- Walk mode and stairs: walk mode follows the floor under your feet, so it climbs stairs, steps down into the basement and follows the ground outside. Walls don't stop you.
- Vaulted ceilings only work on the top floor, under a gable or hip roof. Anywhere else they're shown flat, and the properties panel says so.
- Printing at 1/4" = 1' on letter paper: a page holds about 40 by 27 feet of drawing, so a 40 foot house with its dimension lines takes 2 taped pages (or 1 tabloid sheet), and a 24 by 24 garage fits on one. Front elevations of a house plus garage are wide, about 3 letter pages. That's why each building's plan prints on its own sheet, and why the site plan always uses an engineering scale instead.
- Print at "Actual size": "Fit to page" shrinks the drawing and the scale is wrong. Measure the 1 inch bar to check.
- Lengths are inches everywhere, including the Three.js scene. That needed a logarithmic depth buffer (the camera sees from 6 inches to miles away) to stop flickering surfaces.
- The textures tile seamlessly only when the tile holds whole courses. For example, the brick tile is 80 by 84 inches: 10 bricks across, and 32 courses of 2 5/8 inches.
- A gray tint on the grass looked exactly like a long shadow. The first driveway was just darker ground color, and it read as a shadow. It's now its own concrete surface, 1 inch above the ground.
- The browser pane in my testing tool pauses animation when hidden, so screenshots there need a manual render. A normal browser is fine.
- The BlockCraft codes never leave the LAN: they go from CT 127 to CT 310 over
pct pull/pct pushon the host, and CT 310 keeps them in a file only the app user can read. The family code is never shown on screen. A BlockCraft name can't be taken by an invite sign-up. - Buildings are rectangles. For an L-shaped house use "Wing (L shape)", which shares the wall instead of doubling it.