Initial commit: LynkedUp Fusion site
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/600.css";
|
||||
import "./styles.css";
|
||||
|
||||
console.log("%cCreated by Sarthak", "font-size:14px;font-weight:700;color:#F2A83B;");
|
||||
|
||||
(function(){
|
||||
var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var finePointer = matchMedia("(pointer: fine)").matches;
|
||||
|
||||
/* ---------- scrollbar materialises only while scrolling ---------- */
|
||||
(function(){
|
||||
var to = null;
|
||||
addEventListener("scroll", function(){
|
||||
document.documentElement.classList.add("scrolling");
|
||||
clearTimeout(to);
|
||||
to = setTimeout(function(){ document.documentElement.classList.remove("scrolling"); }, 900);
|
||||
}, { passive: true, capture: true });
|
||||
})();
|
||||
|
||||
/* ---------- mobile scroll progress bar ---------- */
|
||||
(function(){
|
||||
var bar = document.getElementById("mprogBar");
|
||||
if(!bar) return;
|
||||
var raf = null;
|
||||
function upd(){
|
||||
raf = null;
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
var max = Math.max(1, doc.scrollHeight - innerHeight);
|
||||
bar.style.transform = "scaleX(" + Math.min(1, window.scrollY / max).toFixed(4) + ")";
|
||||
}
|
||||
addEventListener("scroll", function(){ if(raf == null) raf = requestAnimationFrame(upd); }, { passive: true });
|
||||
upd();
|
||||
})();
|
||||
|
||||
/* ---------- mobile nav ---------- */
|
||||
(function(){
|
||||
var mb = document.getElementById("menuBtn");
|
||||
if(!mb) return;
|
||||
mb.addEventListener("click", function(){
|
||||
var open = document.body.classList.toggle("nav-open");
|
||||
mb.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
[].slice.call(document.querySelectorAll(".nav-links a")).forEach(function(a){
|
||||
a.addEventListener("click", function(){
|
||||
document.body.classList.remove("nav-open");
|
||||
mb.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* ---------- footer: hail alerts by ZIP ---------- */
|
||||
(function(){
|
||||
var form = document.getElementById("zipForm");
|
||||
if(!form) return;
|
||||
var zin = document.getElementById("zipInput");
|
||||
form.addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
var v = zin.value.trim();
|
||||
if(!/^\d{5}$/.test(v)){ zin.focus(); zin.select(); return; }
|
||||
form.innerHTML = "<div class='fa-done'><span class='tag'>⚑ WATCHING " + v + "</span>" +
|
||||
"<span>First alert lands after the next radar-verified swath. No spam, only storms.</span></div>";
|
||||
});
|
||||
})();
|
||||
|
||||
/* ---------- station rail: generic, built from any [data-rail] section on the page ---------- */
|
||||
(function(){
|
||||
var rail = document.getElementById("rail"), track = document.getElementById("railTrack");
|
||||
if(!rail || !track) return;
|
||||
var car = document.getElementById("railCar"), read = document.getElementById("railRead");
|
||||
var secs = [].slice.call(document.querySelectorAll("[data-rail]")).map(function(el){
|
||||
return { el: el, label: el.getAttribute("data-rail") };
|
||||
});
|
||||
if(!secs.length) return;
|
||||
var nodes = secs.map(function(s){
|
||||
var b = document.createElement("button");
|
||||
b.className = "rail-node"; b.type = "button"; b.title = s.label;
|
||||
b.setAttribute("aria-label", s.label);
|
||||
b.addEventListener("click", function(){
|
||||
scrollTo({top:s.el.offsetTop - 52, behavior:reduced ? "auto" : "smooth"});
|
||||
});
|
||||
track.appendChild(b);
|
||||
return b;
|
||||
});
|
||||
var tops = [], maxScroll = 1;
|
||||
function layout(){
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
maxScroll = Math.max(1, doc.scrollHeight - innerHeight);
|
||||
tops = secs.map(function(s){ return Math.max(0, Math.min(s.el.offsetTop - 52, maxScroll)); });
|
||||
tops.forEach(function(tp, i){ nodes[i].style.top = (tp / maxScroll * 100) + "%"; });
|
||||
}
|
||||
var carP = 0, target = 0, actLabel = "", raf = null, lastT = 0;
|
||||
function frame(now){
|
||||
var rawDt = lastT ? now - lastT : 16;
|
||||
var dt = Math.min(rawDt, 50);
|
||||
lastT = now;
|
||||
var k = rawDt > 400 ? 1 : 1 - Math.exp(-dt / 110);
|
||||
carP += (target - carP) * k;
|
||||
if(Math.abs(target - carP) < .0005) carP = target;
|
||||
car.style.top = (carP * 100) + "%";
|
||||
read.textContent = actLabel + " · " + Math.round(carP * 100) + "%";
|
||||
if(carP === target){ raf = null; lastT = 0; return; }
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function upd(){
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
if(Math.abs(doc.scrollHeight - innerHeight - maxScroll) > 2) layout();
|
||||
var y = window.scrollY;
|
||||
target = Math.max(0, Math.min(1, y / maxScroll));
|
||||
var act = 0;
|
||||
tops.forEach(function(tp, i){ if(y >= tp - innerHeight * .35) act = i; });
|
||||
if(maxScroll - y < 4) act = secs.length - 1;
|
||||
nodes.forEach(function(n, i){ n.classList.toggle("on", i === act); });
|
||||
actLabel = secs[act].label;
|
||||
if(raf == null){ lastT = 0; raf = requestAnimationFrame(frame); }
|
||||
}
|
||||
addEventListener("scroll", upd, {passive:true});
|
||||
addEventListener("resize", function(){ layout(); upd(); });
|
||||
addEventListener("load", function(){ layout(); upd(); });
|
||||
layout(); upd();
|
||||
})();
|
||||
|
||||
/* ---------- scroll reveals: 150ms, 60ms stagger ---------- */
|
||||
var revealEls = [].slice.call(document.querySelectorAll(".reveal"));
|
||||
if(!reduced && "IntersectionObserver" in window){
|
||||
var groups = new Map();
|
||||
var io = new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){
|
||||
if(!e.isIntersecting && e.boundingClientRect.top >= 0) return;
|
||||
io.unobserve(e.target);
|
||||
var parent = e.target.parentElement;
|
||||
var idx = groups.get(parent) || 0;
|
||||
groups.set(parent, idx + 1);
|
||||
setTimeout(function(){ e.target.classList.add("in"); }, idx * 60);
|
||||
setTimeout(function(){ groups.set(parent, Math.max(0,(groups.get(parent)||1)-1)); }, 400);
|
||||
});
|
||||
}, {threshold:.15});
|
||||
revealEls.forEach(function(el){ io.observe(el); });
|
||||
} else {
|
||||
revealEls.forEach(function(el){ el.classList.add("in"); });
|
||||
}
|
||||
|
||||
/* ---------- card tilt physics (spring, with glare tracking) ---------- */
|
||||
if(finePointer && !reduced){
|
||||
[].slice.call(document.querySelectorAll(".risk-card,.founder-card,.feat,.terms-card")).forEach(function(el){
|
||||
var rx=0,ry=0,tx=0,ty=0,vx=0,vy=0,raf=null,over=false;
|
||||
function frame(){
|
||||
vx+=(tx-rx)*.16; vy+=(ty-ry)*.16; vx*=.72; vy*=.72;
|
||||
rx+=vx; ry+=vy;
|
||||
if(!over && Math.abs(rx)<.02 && Math.abs(ry)<.02 && Math.abs(vx)<.02 && Math.abs(vy)<.02){
|
||||
el.style.transform=""; raf=null; return;
|
||||
}
|
||||
el.style.transform="perspective(900px) rotateX("+rx.toFixed(2)+"deg) rotateY("+ry.toFixed(2)+"deg) translateY(-2px)";
|
||||
raf=requestAnimationFrame(frame);
|
||||
}
|
||||
el.addEventListener("pointerenter",function(){
|
||||
over=true; el.classList.add("tilting");
|
||||
if(!raf) raf=requestAnimationFrame(frame);
|
||||
});
|
||||
el.addEventListener("pointermove",function(e){
|
||||
var r=el.getBoundingClientRect();
|
||||
var px=(e.clientX-r.left)/r.width, py=(e.clientY-r.top)/r.height;
|
||||
ty=(px-.5)*5; tx=(.5-py)*5;
|
||||
el.style.setProperty("--gx",(px*100)+"%");
|
||||
el.style.setProperty("--gy",(py*100)+"%");
|
||||
});
|
||||
el.addEventListener("pointerleave",function(){ over=false; tx=0; ty=0; });
|
||||
});
|
||||
}
|
||||
})();
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import "@fontsource-variable/inter";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/600.css";
|
||||
import "./styles.css";
|
||||
|
||||
console.log("%cCreated by Sarthak", "font-size:14px;font-weight:700;color:#F2A83B;");
|
||||
|
||||
(function(){
|
||||
var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
/* ---------- scrollbar materialises only while scrolling ---------- */
|
||||
(function(){
|
||||
var to = null;
|
||||
addEventListener("scroll", function(){
|
||||
document.documentElement.classList.add("scrolling");
|
||||
clearTimeout(to);
|
||||
to = setTimeout(function(){ document.documentElement.classList.remove("scrolling"); }, 900);
|
||||
}, { passive: true, capture: true });
|
||||
})();
|
||||
|
||||
/* ---------- mobile scroll progress bar ---------- */
|
||||
(function(){
|
||||
var bar = document.getElementById("mprogBar");
|
||||
if(!bar) return;
|
||||
var raf = null;
|
||||
function upd(){
|
||||
raf = null;
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
var max = Math.max(1, doc.scrollHeight - innerHeight);
|
||||
bar.style.transform = "scaleX(" + Math.min(1, window.scrollY / max).toFixed(4) + ")";
|
||||
}
|
||||
addEventListener("scroll", function(){ if(raf == null) raf = requestAnimationFrame(upd); }, { passive: true });
|
||||
upd();
|
||||
})();
|
||||
|
||||
/* ---------- mobile nav ---------- */
|
||||
(function(){
|
||||
var mb = document.getElementById("menuBtn");
|
||||
if(!mb) return;
|
||||
mb.addEventListener("click", function(){
|
||||
var open = document.body.classList.toggle("nav-open");
|
||||
mb.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
[].slice.call(document.querySelectorAll(".nav-links a")).forEach(function(a){
|
||||
a.addEventListener("click", function(){
|
||||
document.body.classList.remove("nav-open");
|
||||
mb.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
/* ---------- footer: hail alerts by ZIP ---------- */
|
||||
(function(){
|
||||
var form = document.getElementById("zipForm");
|
||||
if(!form) return;
|
||||
var zin = document.getElementById("zipInput");
|
||||
form.addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
var v = zin.value.trim();
|
||||
if(!/^\d{5}$/.test(v)){ zin.focus(); zin.select(); return; }
|
||||
form.innerHTML = "<div class='fa-done'><span class='tag'>⚑ WATCHING " + v + "</span>" +
|
||||
"<span>First alert lands after the next radar-verified swath. No spam, only storms.</span></div>";
|
||||
});
|
||||
})();
|
||||
|
||||
/* ---------- station rail: section index ---------- */
|
||||
(function(){
|
||||
var rail = document.getElementById("rail"), track = document.getElementById("railTrack");
|
||||
if(!rail || !track) return;
|
||||
var car = document.getElementById("railCar"), read = document.getElementById("railRead");
|
||||
var defs = [
|
||||
["intro","01 · FAQ"], ["ov","02 · OVERVIEW"], ["sales","03 · SALES"], ["canvas","04 · PROCANVAS"],
|
||||
["projects","05 · PROJECTS"], ["ownersbox","06 · OWNERS BOX"], ["ai","07 · AI"],
|
||||
["reporting","08 · REPORTING"], ["fit","09 · FIT"], ["roadmap","10 · ROADMAP"],
|
||||
["founders","11 · FOUNDERS"], ["about","12 · ABOUT"], ["cta","13 · BOOK"]
|
||||
];
|
||||
var secs = defs.map(function(d){ return {el:document.getElementById(d[0]), label:d[1]}; })
|
||||
.filter(function(s){ return s.el; });
|
||||
var nodes = secs.map(function(s){
|
||||
var b = document.createElement("button");
|
||||
b.className = "rail-node"; b.type = "button"; b.title = s.label;
|
||||
b.setAttribute("aria-label", s.label);
|
||||
b.addEventListener("click", function(){
|
||||
scrollTo({top:s.el.offsetTop - 52, behavior:reduced ? "auto" : "smooth"});
|
||||
});
|
||||
track.appendChild(b);
|
||||
return b;
|
||||
});
|
||||
var tops = [], maxScroll = 1;
|
||||
function layout(){
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
maxScroll = Math.max(1, doc.scrollHeight - innerHeight);
|
||||
/* nodes live in the SAME space as the carriage: clamped scroll position / max scroll */
|
||||
tops = secs.map(function(s){ return Math.max(0, Math.min(s.el.offsetTop - 52, maxScroll)); });
|
||||
tops.forEach(function(tp, i){ nodes[i].style.top = (tp / maxScroll * 100) + "%"; });
|
||||
}
|
||||
var carP = 0, target = 0, actLabel = "", raf = null, lastT = 0;
|
||||
function frame(now){
|
||||
var rawDt = lastT ? now - lastT : 16;
|
||||
var dt = Math.min(rawDt, 50);
|
||||
lastT = now;
|
||||
var k = rawDt > 400 ? 1 : 1 - Math.exp(-dt / 110);
|
||||
carP += (target - carP) * k;
|
||||
if(Math.abs(target - carP) < .0005) carP = target;
|
||||
car.style.top = (carP * 100) + "%";
|
||||
read.textContent = actLabel + " · " + Math.round(carP * 100) + "%";
|
||||
if(carP === target){ raf = null; lastT = 0; return; }
|
||||
raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function upd(){
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
if(Math.abs(doc.scrollHeight - innerHeight - maxScroll) > 2) layout();
|
||||
var y = window.scrollY;
|
||||
target = Math.max(0, Math.min(1, y / maxScroll));
|
||||
var act = 0;
|
||||
tops.forEach(function(tp, i){ if(y >= tp - innerHeight * .35) act = i; });
|
||||
if(maxScroll - y < 4) act = secs.length - 1;
|
||||
nodes.forEach(function(n, i){ n.classList.toggle("on", i === act); });
|
||||
actLabel = secs[act].label;
|
||||
if(raf == null){ lastT = 0; raf = requestAnimationFrame(frame); }
|
||||
}
|
||||
addEventListener("scroll", upd, {passive:true});
|
||||
addEventListener("resize", function(){ layout(); upd(); });
|
||||
addEventListener("load", function(){ layout(); upd(); });
|
||||
layout(); upd();
|
||||
})();
|
||||
|
||||
/* ---------- scroll reveals: 150ms, 60ms stagger ---------- */
|
||||
var revealEls = [].slice.call(document.querySelectorAll(".reveal"));
|
||||
if(!reduced && "IntersectionObserver" in window){
|
||||
var groups = new Map();
|
||||
var io = new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){
|
||||
if(!e.isIntersecting && e.boundingClientRect.top >= 0) return;
|
||||
io.unobserve(e.target);
|
||||
var parent = e.target.parentElement;
|
||||
var idx = groups.get(parent) || 0;
|
||||
groups.set(parent, idx + 1);
|
||||
setTimeout(function(){ e.target.classList.add("in"); }, idx * 60);
|
||||
setTimeout(function(){ groups.set(parent, Math.max(0,(groups.get(parent)||1)-1)); }, 400);
|
||||
});
|
||||
}, {threshold:.15});
|
||||
revealEls.forEach(function(el){ io.observe(el); });
|
||||
} else {
|
||||
revealEls.forEach(function(el){ el.classList.add("in"); });
|
||||
}
|
||||
|
||||
/* ---------- FAQ search filter ---------- */
|
||||
(function(){
|
||||
var input = document.getElementById("faqSearch");
|
||||
if(!input) return;
|
||||
var count = document.getElementById("faqCount");
|
||||
var groups = [].slice.call(document.querySelectorAll(".faq-cat")).map(function(cat){
|
||||
return {
|
||||
cat: cat,
|
||||
rows: [].slice.call(cat.querySelectorAll(".faq details")).map(function(d){
|
||||
return { el: d, text: d.textContent.toLowerCase() };
|
||||
})
|
||||
};
|
||||
});
|
||||
function run(){
|
||||
var q = input.value.trim().toLowerCase();
|
||||
var shown = 0;
|
||||
groups.forEach(function(group){
|
||||
var visible = 0;
|
||||
group.rows.forEach(function(row){
|
||||
var match = !q || row.text.indexOf(q) !== -1;
|
||||
row.el.classList.toggle("hide", !match);
|
||||
if(match) visible++;
|
||||
});
|
||||
group.cat.classList.toggle("hide", visible === 0);
|
||||
shown += visible;
|
||||
});
|
||||
if(count) count.textContent = q ? shown + " match" + (shown === 1 ? "" : "es") + " for “" + input.value.trim() + "”" : "";
|
||||
dispatchEvent(new Event("resize"));
|
||||
}
|
||||
input.addEventListener("input", run);
|
||||
})();
|
||||
})();
|
||||
+1459
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,703 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
const v = (x, y, z) => new THREE.Vector3(x, y, z);
|
||||
|
||||
function mk(geo, mat) {
|
||||
const m = new THREE.Mesh(geo, mat);
|
||||
m.castShadow = true;
|
||||
m.receiveShadow = true;
|
||||
return m;
|
||||
}
|
||||
function box(w, h, d, mat, x = 0, y = 0, z = 0) {
|
||||
const m = mk(new THREE.BoxGeometry(w, h, d), mat);
|
||||
m.position.set(x, y, z);
|
||||
return m;
|
||||
}
|
||||
function quadGeo(a, b, c, d) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setFromPoints([a, b, c, a, c, d]);
|
||||
g.computeVertexNormals();
|
||||
const uv = new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]);
|
||||
g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
|
||||
return g;
|
||||
}
|
||||
function triGeo(a, b, c) {
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setFromPoints([a, b, c]);
|
||||
g.computeVertexNormals();
|
||||
g.setAttribute("uv", new THREE.BufferAttribute(new Float32Array([0, 0, 1, 0, 0.5, 1]), 2));
|
||||
return g;
|
||||
}
|
||||
/* thin cap box laid along segment a->b (ridge caps, hip caps, highlights) */
|
||||
function capAlong(a, b, mat, h = 0.55, w = 1.35) {
|
||||
const len = a.distanceTo(b);
|
||||
const m = box(len, h, w, mat);
|
||||
m.position.copy(a).add(b).multiplyScalar(0.5);
|
||||
const dir = v(0, 0, 0).subVectors(b, a).normalize();
|
||||
m.quaternion.setFromUnitVectors(v(1, 0, 0), dir);
|
||||
return m;
|
||||
}
|
||||
|
||||
/* ---------------- roof builders (geometry == measurement source) ------------- */
|
||||
|
||||
function gableRoof({ span, len, pitch, oE = 1.5, oG = 1.2, y0, matFront, matBack, capMat, fasciaMat }) {
|
||||
const p = pitch / 12, th = 0.45;
|
||||
const theta = Math.atan(p);
|
||||
const rise = (span / 2) * p;
|
||||
const ridgeY = y0 + rise;
|
||||
const halfH = span / 2 + oE;
|
||||
const slopeLen = halfH / Math.cos(theta);
|
||||
const eaveY = ridgeY - halfH * p;
|
||||
const g = new THREE.Group();
|
||||
const geo = new THREE.BoxGeometry(len + 2 * oG, th, slopeLen);
|
||||
for (const s of [1, -1]) {
|
||||
const m = mk(geo, s === 1 ? matFront : matBack);
|
||||
m.rotation.x = s * theta;
|
||||
m.position.set(0, (ridgeY + eaveY) / 2 + th * 0.5, (s * halfH) / 2);
|
||||
g.add(m);
|
||||
}
|
||||
g.add(box(len + 2 * oG + 0.3, 0.5, 1.5, capMat, 0, ridgeY + 0.45, 0));
|
||||
for (const s of [1, -1]) g.add(box(len + 2 * oG, 0.85, 0.4, fasciaMat, 0, eaveY, s * halfH));
|
||||
const hl = len / 2 + oG;
|
||||
return {
|
||||
g,
|
||||
area: 2 * (len + 2 * oG) * slopeLen,
|
||||
capLf: len,
|
||||
ridgeY, eaveY,
|
||||
ridgeSeg: [v(-hl, ridgeY + 0.5, 0), v(hl, ridgeY + 0.5, 0)],
|
||||
eaveSegs: [
|
||||
[v(-hl, eaveY, halfH), v(hl, eaveY, halfH)],
|
||||
[v(-hl, eaveY, -halfH), v(hl, eaveY, -halfH)],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function hipRoof({ span, len, pitch, oE = 1.5, y0, mat, capMat, fasciaMat }) {
|
||||
const p = pitch / 12;
|
||||
const rise = (span / 2) * p;
|
||||
const ridgeY = y0 + rise;
|
||||
const eaveY = y0 - oE * p;
|
||||
const dY = ridgeY - eaveY;
|
||||
const halfW = span / 2 + oE, halfL = len / 2 + oE;
|
||||
const rl = Math.max(0, len - span) / 2;
|
||||
const R1 = v(-rl, ridgeY, 0), R2 = v(rl, ridgeY, 0);
|
||||
const E = [v(-halfL, eaveY, halfW), v(halfL, eaveY, halfW), v(halfL, eaveY, -halfW), v(-halfL, eaveY, -halfW)];
|
||||
const g = new THREE.Group();
|
||||
g.add(mk(quadGeo(E[0], E[1], R2, R1), mat));
|
||||
g.add(mk(quadGeo(E[2], E[3], R1, R2), mat));
|
||||
g.add(mk(triGeo(E[1], E[2], R2), mat));
|
||||
g.add(mk(triGeo(E[3], E[0], R1), mat));
|
||||
for (const s of [1, -1]) g.add(box(2 * halfL, 0.85, 0.4, fasciaMat, 0, eaveY, s * halfW));
|
||||
for (const s of [1, -1]) {
|
||||
const f = box(2 * halfW, 0.85, 0.4, fasciaMat, s * halfL, eaveY, 0);
|
||||
f.rotation.y = Math.PI / 2;
|
||||
g.add(f);
|
||||
}
|
||||
const hipSegs = [[E[0], R1], [E[3], R1], [E[1], R2], [E[2], R2]];
|
||||
hipSegs.forEach((s) => g.add(capAlong(s[0].clone().add(v(0, 0.35, 0)), s[1].clone().add(v(0, 0.35, 0)), capMat)));
|
||||
if (rl > 0.1) g.add(box(rl * 2 + 0.4, 0.5, 1.5, capMat, 0, ridgeY + 0.4, 0));
|
||||
const slopeLen = Math.hypot(halfW, dY);
|
||||
const endSlope = Math.hypot(halfL - rl, dY);
|
||||
const area = 2 * ((2 * halfL + 2 * rl) / 2) * slopeLen + 2 * 0.5 * (2 * halfW) * endSlope;
|
||||
const capLf = rl * 2 + hipSegs.reduce((a, s) => a + s[0].distanceTo(s[1]), 0);
|
||||
return { g, area, capLf, ridgeY, eaveY, ridgeSeg: [R1.clone().add(v(0, 0.6, 0)), R2.clone().add(v(0, 0.6, 0))], eaveSegs: [[E[0], E[1]], [E[3], E[2]]], hipSegs };
|
||||
}
|
||||
|
||||
/* ---------------- kit parts ------------------------------------------------ */
|
||||
|
||||
function windowUnit(M, w = 3.2, h = 4.4) {
|
||||
const g = new THREE.Group();
|
||||
g.add(box(w + 0.55, h + 0.55, 0.32, M.trim));
|
||||
const gl = box(w, h, 0.16, M.glass, 0, 0, 0.12);
|
||||
g.add(gl);
|
||||
g.add(box(w, 0.14, 0.1, M.trim, 0, 0, 0.22));
|
||||
g.add(box(0.14, h, 0.1, M.trim, 0, 0, 0.22));
|
||||
return g;
|
||||
}
|
||||
function doorUnit(M, mat, w = 3.1, h = 6.9) {
|
||||
const g = new THREE.Group();
|
||||
g.add(box(w + 0.6, h + 0.4, 0.3, M.trim, 0, 0.1, 0));
|
||||
g.add(box(w, h, 0.24, mat, 0, 0, 0.12));
|
||||
g.add(box(0.5, 0.12, 0.34, M.trim, w / 2 - 0.5, 0, 0.14));
|
||||
return g;
|
||||
}
|
||||
function put(parent, obj, x, y, z, rotY = 0) {
|
||||
obj.position.set(x, y, z);
|
||||
obj.rotation.y = rotY;
|
||||
parent.add(obj);
|
||||
return obj;
|
||||
}
|
||||
function chimney(M, h, x, z, w = 3.4, d = 3) {
|
||||
const g = new THREE.Group();
|
||||
g.add(box(w, h, d, M.brick, 0, h / 2, 0));
|
||||
g.add(box(w + 0.8, 0.6, d + 0.8, M.concrete, 0, h + 0.3, 0));
|
||||
g.add(box(1.3, 1, 1.1, M.porchRoof, 0, h + 1, 0));
|
||||
g.position.set(x, 0, z);
|
||||
return g;
|
||||
}
|
||||
function tree(M, s = 1) {
|
||||
const g = new THREE.Group();
|
||||
const trunk = mk(new THREE.CylinderGeometry(0.45 * s, 0.7 * s, 7 * s, 7), M.trunk);
|
||||
trunk.position.y = 3.5 * s;
|
||||
g.add(trunk);
|
||||
[[0, 9.6, 0, 4.3], [2.1, 7.8, 1.1, 3], [-1.9, 8.2, -0.8, 3.2]].forEach((f, i) => {
|
||||
const fol = mk(new THREE.IcosahedronGeometry(f[3] * s, 1), i === 1 ? M.foliage2 : M.foliage);
|
||||
fol.position.set(f[0] * s, f[1] * s, f[2] * s);
|
||||
g.add(fol);
|
||||
});
|
||||
return g;
|
||||
}
|
||||
function shrub(M, s = 1) {
|
||||
const m = mk(new THREE.IcosahedronGeometry(1.15 * s, 1), M.foliage2);
|
||||
m.scale.y = 0.75;
|
||||
m.position.y = 0.85 * s;
|
||||
return m;
|
||||
}
|
||||
function mailbox(M) {
|
||||
const g = new THREE.Group();
|
||||
g.userData.ink = "site";
|
||||
g.add(box(0.28, 3.6, 0.28, M.trunk, 0, 1.8, 0));
|
||||
g.add(box(1.5, 0.9, 0.8, M.porchRoof, 0.3, 3.9, 0));
|
||||
return g;
|
||||
}
|
||||
function pediment(M, mat, span, rise, x, y0, alongZ = false) {
|
||||
const a = alongZ ? v(x, y0, -span / 2) : v(-span / 2, y0, x);
|
||||
const b = alongZ ? v(x, y0, span / 2) : v(span / 2, y0, x);
|
||||
const c = alongZ ? v(x, y0 + rise, 0) : v(0, y0 + rise, x);
|
||||
const mm = mk(triGeo(a, b, c), mat);
|
||||
mm.material = mm.material.clone();
|
||||
mm.material.side = THREE.DoubleSide;
|
||||
return mm;
|
||||
}
|
||||
|
||||
function hlFromSegs(M, segs, w = 1.7) {
|
||||
const g = new THREE.Group();
|
||||
segs.forEach((s) => g.add(capAlong(s[0].clone().add(v(0, 0.35, 0)), s[1].clone().add(v(0, 0.35, 0)), M.hlAmber, 0.7, w)));
|
||||
g.visible = false;
|
||||
return g;
|
||||
}
|
||||
|
||||
/* translucent wash over the main roof slopes, so the "roof" line-item hover reads
|
||||
as prominently as the ridge/eave bar highlights instead of just a thin edge tint */
|
||||
function hlRoofWash(M, roof, hip) {
|
||||
const lift = v(0, 0.12, 0);
|
||||
const up = (p) => p.clone().add(lift);
|
||||
const R = roof.ridgeSeg, E = roof.eaveSegs;
|
||||
const faces = [
|
||||
quadGeo(up(E[0][0]), up(E[0][1]), up(R[1]), up(R[0])),
|
||||
quadGeo(up(E[1][1]), up(E[1][0]), up(R[0]), up(R[1])),
|
||||
];
|
||||
if (hip) {
|
||||
faces.push(triGeo(up(E[0][1]), up(E[1][1]), up(R[1])));
|
||||
faces.push(triGeo(up(E[1][0]), up(E[0][0]), up(R[0])));
|
||||
}
|
||||
const g = new THREE.Group();
|
||||
faces.forEach((geo) => {
|
||||
const m = new THREE.Mesh(geo, M.hlFace);
|
||||
m.castShadow = false;
|
||||
m.receiveShadow = false;
|
||||
g.add(m);
|
||||
});
|
||||
g.visible = false;
|
||||
g.userData.ink = "skip";
|
||||
return g;
|
||||
}
|
||||
|
||||
/* ---------------- the three houses ---------------------------------------- */
|
||||
|
||||
export function buildGable(M) {
|
||||
const g = new THREE.Group();
|
||||
const MW = 42, MD = 28, WH = 19, PITCH = 6;
|
||||
const rise = (MD / 2) * (PITCH / 12);
|
||||
const roofF = M.shingle.clone(), roofB = M.shingle.clone();
|
||||
const roofG1 = M.shingle.clone(), roofG2 = M.shingle.clone();
|
||||
|
||||
g.add(box(MW + 1, 1.4, MD + 1, M.concrete, 0, 0.7, 0)); /* foundation plinth */
|
||||
g.add(box(MW, WH, MD, M.sidingWhite, 0, WH / 2 + 1, 0));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, MW / 2, WH + 1, true));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, -MW / 2, WH + 1, true));
|
||||
const roof = gableRoof({ span: MD, len: MW, pitch: PITCH, y0: WH + 1, matFront: roofF, matBack: roofB, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
g.add(roof.g);
|
||||
const roofWash = hlRoofWash(M, roof, false);
|
||||
g.add(roofWash);
|
||||
|
||||
/* attached garage, gable facing the street */
|
||||
const GW = 20, GD = 22, GH = 10;
|
||||
const gx = MW / 2 + GW / 2 - 0.2, gz = MD / 2 - GD / 2;
|
||||
g.add(box(GW + 0.8, 1.2, GD + 0.8, M.concrete, gx, 0.6, gz));
|
||||
g.add(box(GW, GH, GD, M.sidingWhite, gx, GH / 2 + 1, gz));
|
||||
const grise = (GW / 2) * (PITCH / 12);
|
||||
const gRoofGrp = new THREE.Group();
|
||||
const gRoof = gableRoof({ span: GW, len: GD, pitch: PITCH, y0: GH + 1, matFront: roofG1, matBack: roofG2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
gRoofGrp.add(gRoof.g);
|
||||
gRoofGrp.rotation.y = Math.PI / 2;
|
||||
gRoofGrp.position.set(gx, 0, gz);
|
||||
g.add(gRoofGrp);
|
||||
const pedF = pediment(M, M.sidingWhite, GW, grise, 0, GH + 1, false);
|
||||
pedF.position.set(gx, 0, MD / 2 - 0.01);
|
||||
g.add(pedF);
|
||||
put(g, doorUnit(M, M.garage, 16, 7.6), gx, 4.9, MD / 2 + 0.1);
|
||||
|
||||
put(g, chimney(M, WH + rise + 5, -MW / 2 - 1.7, -4));
|
||||
|
||||
/* porch over the front door */
|
||||
const px = -8;
|
||||
g.add(box(9.5, 0.9, 5.5, M.concrete, px, 0.45, MD / 2 + 2.6));
|
||||
[[-4, 2.2], [4, 2.2], [-4, 4.9], [4, 4.9]].forEach((pp) =>
|
||||
g.add(box(0.55, 8.6, 0.55, M.trim, px + pp[0], 5.2, MD / 2 + pp[1])));
|
||||
const pr = box(11, 0.4, 7, M.porchRoof, px, 10.2, MD / 2 + 2.8);
|
||||
pr.rotation.x = 0.12;
|
||||
g.add(pr);
|
||||
put(g, doorUnit(M, M.doorRed), px, 4.45, MD / 2 + 0.12);
|
||||
|
||||
/* windows */
|
||||
const F = MD / 2 + 0.1;
|
||||
[-16, -8, 0, 8, 16].forEach((x) => put(g, windowUnit(M), x, 15.6, F));
|
||||
[-16, 0, 8, 16].forEach((x) => put(g, windowUnit(M, 3.2, 5), x, 6.4, F));
|
||||
[-14, -4, 6, 16].forEach((x) => put(g, windowUnit(M), x, 15.6, -F));
|
||||
[-14, 4, 14].forEach((x) => put(g, windowUnit(M, 3.2, 5), x, 6.4, -F));
|
||||
[-6, 6].forEach((z) => put(g, windowUnit(M), -MW / 2 - 0.1, 15.6, z, -Math.PI / 2));
|
||||
put(g, windowUnit(M, 2.6, 3.4), gx + GW / 2 + 0.1, 6, gz, Math.PI / 2);
|
||||
|
||||
/* grounds */
|
||||
g.add(box(17, 0.14, 30, M.asphalt, gx, 0.08, MD / 2 + 15));
|
||||
g.add(box(3.4, 0.12, 26, M.concrete, px, 0.07, MD / 2 + 13));
|
||||
put(g, mailbox(M), px + 5, 0, 41);
|
||||
put(g, tree(M, 1.15), -33, 0, -15);
|
||||
put(g, tree(M, 0.9), 29, 0, -24);
|
||||
put(g, tree(M, 0.75), -31, 0, 14);
|
||||
[-18, -13, -3, 3, 13, 18].forEach((x, i) => put(g, shrub(M, 1 + (i % 3) * 0.18), x, 0, MD / 2 + 2.2));
|
||||
|
||||
const area = Math.round(roof.area + gRoof.area);
|
||||
return {
|
||||
group: g,
|
||||
measure: { roofSqFt: area, ridgeLf: Math.round(roof.capLf + gRoof.capLf), facets: 5 },
|
||||
anchors: {
|
||||
spanA: v(-MW / 2, 0.4, MD / 2), spanB: v(MW / 2, 0.4, MD / 2),
|
||||
peakTop: v(-MW / 2, WH + 1 + rise + 0.6, 0), peakBase: v(-MW / 2, 0, 0),
|
||||
pitchAt: v(-MW / 2 - 1.2, roof.eaveY, MD / 2 + 1.5),
|
||||
},
|
||||
hl: {
|
||||
ridge: hlFromSegs(M, [roof.ridgeSeg, [v(gx, gRoof.ridgeY + 0.5, gz - GD / 2 - 1.2), v(gx, gRoof.ridgeY + 0.5, gz + GD / 2 + 1.2)]]),
|
||||
eave: hlFromSegs(M, roof.eaveSegs),
|
||||
roof: roofWash,
|
||||
roofMats: [roofF, roofB, roofG1, roofG2],
|
||||
damage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHip(M) {
|
||||
const g = new THREE.Group();
|
||||
const RW = 46, RD = 26, WH = 10.5, PITCH = 6;
|
||||
const roofM = M.shingleWarm.clone(), roofG = M.shingleWarm.clone(), roofS = M.shingleWarm.clone();
|
||||
|
||||
g.add(box(RW + 1, 1.2, RD + 1, M.concrete, 0, 0.6, 0));
|
||||
g.add(box(RW, WH, RD, M.sidingBeige, 0, WH / 2 + 1, 0));
|
||||
const roof = hipRoof({ span: RD, len: RW, pitch: PITCH, y0: WH + 1, mat: roofM, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
g.add(roof.g);
|
||||
const roofWash = hlRoofWash(M, roof, true);
|
||||
g.add(roofWash);
|
||||
|
||||
/* attached hip garage, protruding toward the street */
|
||||
const GW = 20, GH = 9;
|
||||
const gx = 14, gz = RD / 2 + GW / 2 - 1;
|
||||
g.add(box(GW + 0.8, 1.1, GW + 0.8, M.concrete, gx, 0.55, gz));
|
||||
g.add(box(GW, GH, GW, M.sidingBeige, gx, GH / 2 + 1, gz));
|
||||
const gRoof = hipRoof({ span: GW, len: GW, pitch: PITCH, y0: GH + 1, mat: roofG, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
gRoof.g.position.set(gx, 0, gz);
|
||||
g.add(gRoof.g);
|
||||
put(g, doorUnit(M, M.garage, 15, 7.2), gx, 4.7, gz + GW / 2 + 0.1);
|
||||
|
||||
/* windows + door on the clear side of the facade */
|
||||
const F = RD / 2 + 0.1;
|
||||
put(g, doorUnit(M, M.doorBlue), -2, 4.45, F);
|
||||
[-14.5, -11.2, -7.9].forEach((x) => put(g, windowUnit(M, 2.9, 4.2), x, 6, F));
|
||||
put(g, windowUnit(M, 3.2, 4.2), -19.5, 6, F);
|
||||
[-12, 0, 12].forEach((x) => put(g, windowUnit(M, 3.2, 4.2), x, 6, -F));
|
||||
[-4, 5].forEach((z) => put(g, windowUnit(M, 3, 4), -RW / 2 - 0.1, 6, z, -Math.PI / 2));
|
||||
|
||||
/* backyard shed */
|
||||
const shed = new THREE.Group();
|
||||
shed.add(box(10, 6.5, 8, M.sidingGray, 0, 3.25, 0));
|
||||
const sr = gableRoof({ span: 8, len: 10, pitch: 6, oE: 0.8, oG: 0.7, y0: 6.5, matFront: roofS, matBack: roofS, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
shed.add(sr.g);
|
||||
shed.add(pediment(M, M.sidingGray, 8, 2, 5, 6.5, true));
|
||||
shed.add(pediment(M, M.sidingGray, 8, 2, -5, 6.5, true));
|
||||
put(shed, doorUnit(M, M.doorBlue, 2.6, 5.4), 0, 3.7, 4.1);
|
||||
shed.position.set(-26, 0, -19);
|
||||
shed.rotation.y = 0.35;
|
||||
g.add(shed);
|
||||
|
||||
/* rear terrace with pergola */
|
||||
g.add(box(18, 0.3, 12, M.concrete, 4, 0.15, -RD / 2 - 6));
|
||||
[[-8, -1.5], [8, -1.5], [-8, -10.5], [8, -10.5]].forEach((pp) =>
|
||||
g.add(box(0.6, 8.4, 0.6, M.trim, 4 + pp[0], 4.2, -RD / 2 + pp[1])));
|
||||
for (let i = 0; i < 5; i++) g.add(box(18.5, 0.22, 0.9, M.trim, 4, 8.5, -RD / 2 - 2 - i * 2.1));
|
||||
|
||||
/* grounds */
|
||||
g.add(box(17, 0.14, 13, M.asphalt, gx, 0.08, gz + GW / 2 + 6.5));
|
||||
g.add(box(3, 0.12, 29, M.concrete, -2, 0.07, F + 14.5));
|
||||
put(g, mailbox(M), 3, 0, 42);
|
||||
put(g, tree(M, 1.3), -31, 0, 8);
|
||||
put(g, tree(M, 1), 30, 0, -14);
|
||||
[-16, -6, 2].forEach((x, i) => put(g, shrub(M, 1 + i * 0.15), x, 0, F + 1.8));
|
||||
|
||||
return {
|
||||
group: g,
|
||||
measure: { roofSqFt: Math.round(roof.area + gRoof.area + sr.area), ridgeLf: Math.round(roof.capLf + gRoof.capLf + sr.capLf), facets: 10 },
|
||||
anchors: {
|
||||
spanA: v(-RW / 2, 0.4, RD / 2), spanB: v(RW / 2, 0.4, RD / 2),
|
||||
peakTop: v(-(RW - RD) / 2, roof.ridgeY + 0.8, 0), peakBase: v(-(RW - RD) / 2, 0, 0),
|
||||
pitchAt: v(-RW / 2 - 1.5, roof.eaveY, RD / 2 + 1.5),
|
||||
},
|
||||
hl: {
|
||||
ridge: hlFromSegs(M, [roof.ridgeSeg].concat(roof.hipSegs)),
|
||||
eave: hlFromSegs(M, roof.eaveSegs),
|
||||
roof: roofWash,
|
||||
roofMats: [roofM, roofG, roofS],
|
||||
damage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStorm(M) {
|
||||
const g = new THREE.Group();
|
||||
const FW = 58, FD = 28, WH = 18, PITCH = 5;
|
||||
const rise = (FD / 2) * (PITCH / 12);
|
||||
const roofF = M.shingleStorm.clone(), roofB = M.shingle.clone(), roofW = M.shingle.clone();
|
||||
|
||||
g.add(box(FW + 1, 1.4, FD + 1, M.concrete, 0, 0.7, 0));
|
||||
g.add(box(FW, WH, FD, M.sidingGray, 0, WH / 2 + 1, 0));
|
||||
g.add(pediment(M, M.sidingGray, FD, rise, FW / 2, WH + 1, true));
|
||||
g.add(pediment(M, M.sidingGray, FD, rise, -FW / 2, WH + 1, true));
|
||||
const roof = gableRoof({ span: FD, len: FW, pitch: PITCH, y0: WH + 1, matFront: roofF, matBack: roofB, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
g.add(roof.g);
|
||||
const roofWash = hlRoofWash(M, roof, false);
|
||||
g.add(roofWash);
|
||||
put(g, chimney(M, WH + rise + 6, 7, 0));
|
||||
|
||||
/* rear single-storey wing (the L that makes this a 30-square claim) */
|
||||
const WGW = 24, WGL = 20, WGH = 10;
|
||||
const wx = -8, wz = -FD / 2 - WGL / 2 + 0.5;
|
||||
g.add(box(WGW + 0.8, 1.2, WGL + 0.8, M.concrete, wx, 0.6, wz));
|
||||
g.add(box(WGW, WGH, WGL, M.sidingGray, wx, WGH / 2 + 1, wz));
|
||||
const wrise = (WGW / 2) * (PITCH / 12);
|
||||
const wRoofGrp = new THREE.Group();
|
||||
const wRoof = gableRoof({ span: WGW, len: WGL, pitch: PITCH, y0: WGH + 1, matFront: roofW, matBack: roofW, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
wRoofGrp.add(wRoof.g);
|
||||
wRoofGrp.rotation.y = Math.PI / 2;
|
||||
wRoofGrp.position.set(wx, 0, wz);
|
||||
g.add(wRoofGrp);
|
||||
const wPed = pediment(M, M.sidingGray, WGW, wrise, 0, WGH + 1, false);
|
||||
wPed.position.set(wx, 0, wz - WGL / 2 - 0.01);
|
||||
wPed.rotation.y = Math.PI;
|
||||
g.add(wPed);
|
||||
[-6, 6].forEach((dx) => put(g, windowUnit(M, 3, 4), wx + dx, 6, wz - WGL / 2 - 0.1, Math.PI));
|
||||
|
||||
/* full-width front porch */
|
||||
const F = FD / 2;
|
||||
g.add(box(FW - 2, 0.9, 7, M.concrete, 0, 0.45, F + 3.4));
|
||||
[-26, -13, 0, 13, 26].forEach((x) => g.add(box(0.6, 9.3, 0.6, M.trim, x, 5.55, F + 6.2)));
|
||||
const pr = box(FW, 0.4, 8.6, M.porchRoof, 0, 11, F + 3.6);
|
||||
pr.rotation.x = 0.1;
|
||||
g.add(pr);
|
||||
|
||||
put(g, doorUnit(M, M.doorBlue, 3.4, 7), 0, 4.5, F + 0.12);
|
||||
[-23, -11.5, 11.5, 23].forEach((x) => put(g, windowUnit(M, 3.2, 4.6), x, 6.4, F + 0.1));
|
||||
[-23, -11.5, 0, 11.5, 23].forEach((x) => put(g, windowUnit(M), x, 15.4, F + 0.1));
|
||||
[-25, 9, 17, 25].forEach((x) => put(g, windowUnit(M), x, 15.4, -F - 0.1));
|
||||
[10, 18, 26].forEach((x) => put(g, windowUnit(M, 3.2, 4.6), x, 6.4, -F - 0.1));
|
||||
[-5, 5].forEach((z) => put(g, windowUnit(M, 3, 4.2), -FW / 2 - 0.1, 14, z, -Math.PI / 2));
|
||||
|
||||
/* tarped section on the damaged plane */
|
||||
const p = PITCH / 12;
|
||||
const ry = WH + 1 + rise;
|
||||
const tarp = mk(quadGeo(
|
||||
v(9, ry + 0.55, 0.4), v(21, ry + 0.55, 0.4),
|
||||
v(21.6, ry - 8.2 * p + 0.55, 8.6), v(8.4, ry - 8.2 * p + 0.55, 8.6)
|
||||
), M.tarp);
|
||||
g.add(tarp);
|
||||
|
||||
/* 41 hail impacts clustered on the front plane (facet F2) */
|
||||
const impactMat = M.impact.clone();
|
||||
const imp = new THREE.InstancedMesh(new THREE.SphereGeometry(0.36, 8, 6), impactMat, 41);
|
||||
const dm = new THREE.Matrix4();
|
||||
let sd = 4021;
|
||||
const rr = () => ((sd = (sd * 16807) % 2147483647) / 2147483647);
|
||||
for (let i = 0; i < 41; i++) {
|
||||
const cl = rr() < 0.62;
|
||||
const x = cl ? -18 + rr() * 20 : -28 + rr() * 56;
|
||||
const t = 0.12 + rr() * 0.8;
|
||||
const z = t * (FD / 2 + 1.2);
|
||||
const y = ry - z * p + 0.45;
|
||||
dm.makeScale(1, 0.4, 1);
|
||||
dm.setPosition(x, y, z);
|
||||
imp.setMatrixAt(i, dm);
|
||||
}
|
||||
imp.castShadow = false;
|
||||
g.add(imp);
|
||||
|
||||
/* storm-yard: debris, fallen limb */
|
||||
let s2 = 99;
|
||||
const r2 = () => ((s2 = (s2 * 48271) % 2147483647) / 2147483647);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const d = box(1.6 + r2() * 2, 0.18, 1 + r2(), M.porchRoof, -20 + r2() * 44, 0.1, F + 8 + r2() * 14);
|
||||
d.rotation.y = r2() * 3;
|
||||
g.add(d);
|
||||
}
|
||||
const limb = mk(new THREE.CylinderGeometry(0.35, 0.5, 9, 6), M.trunk);
|
||||
limb.rotation.z = Math.PI / 2 - 0.06;
|
||||
limb.rotation.y = 0.5;
|
||||
limb.position.set(27, 0.5, 16);
|
||||
g.add(limb);
|
||||
|
||||
g.add(box(13, 0.14, 30, M.asphalt, 30, 0.08, F + 16));
|
||||
g.add(box(3.2, 0.12, 22, M.concrete, 0, 0.07, F + 18));
|
||||
put(g, mailbox(M), -5, 0, 42);
|
||||
put(g, tree(M, 1.2), -34, 0, -10);
|
||||
put(g, tree(M, 1.05), 34, 0, -18);
|
||||
|
||||
return {
|
||||
group: g,
|
||||
measure: { roofSqFt: Math.round(roof.area + wRoof.area), ridgeLf: Math.round(roof.capLf + wRoof.capLf), facets: 4 },
|
||||
anchors: {
|
||||
spanA: v(-FW / 2, 0.4, FD / 2), spanB: v(FW / 2, 0.4, FD / 2),
|
||||
peakTop: v(-FW / 2, ry + 0.7, 0), peakBase: v(-FW / 2, 0, 0),
|
||||
pitchAt: v(-FW / 2 - 1.5, roof.eaveY, FD / 2 + 1.5),
|
||||
damageAt: v(-8, ry - 4 * p, 4.5),
|
||||
},
|
||||
hl: {
|
||||
ridge: hlFromSegs(M, [roof.ridgeSeg]),
|
||||
eave: hlFromSegs(M, roof.eaveSegs),
|
||||
roof: roofWash,
|
||||
roofMats: [roofF, roofB, roofW],
|
||||
damage: { mesh: imp, mat: impactMat, base: 0.9 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* multi-wing estate: two perpendicular gable wings plus a rear nub, wrapping a
|
||||
courtyard pool — modelled after a client-supplied aerial survey of a complex
|
||||
multi-facet roof (5705 Bent Oak Place, Dallas TX). */
|
||||
export function buildEstate(M) {
|
||||
const g = new THREE.Group();
|
||||
const MW = 42, MD = 28, WH = 19, PITCH = 6;
|
||||
const rise = (MD / 2) * (PITCH / 12);
|
||||
const roofF = M.shingle.clone(), roofB = M.shingle.clone();
|
||||
const roofW1 = M.shingle.clone(), roofW2 = M.shingle.clone();
|
||||
const roofN1 = M.shingle.clone(), roofN2 = M.shingle.clone();
|
||||
|
||||
g.add(box(MW + 1, 1.4, MD + 1, M.concrete, 0, 0.7, 0)); /* foundation plinth */
|
||||
g.add(box(MW, WH, MD, M.sidingWhite, 0, WH / 2 + 1, 0));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, MW / 2, WH + 1, true));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, -MW / 2, WH + 1, true));
|
||||
const roof = gableRoof({ span: MD, len: MW, pitch: PITCH, y0: WH + 1, matFront: roofF, matBack: roofB, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
g.add(roof.g);
|
||||
const roofWash = hlRoofWash(M, roof, false);
|
||||
g.add(roofWash);
|
||||
put(g, chimney(M, WH + rise + 5, -MW / 2 - 1.7, -4));
|
||||
|
||||
/* wing A: perpendicular guest wing, front-flush, closes one side of the courtyard */
|
||||
const GW = 20, GD = 22, GH = 10;
|
||||
const gx = MW / 2 + GW / 2 - 0.2, gz = MD / 2 - GD / 2;
|
||||
g.add(box(GW + 0.8, 1.2, GD + 0.8, M.concrete, gx, 0.6, gz));
|
||||
g.add(box(GW, GH, GD, M.sidingWhite, gx, GH / 2 + 1, gz));
|
||||
const grise = (GW / 2) * (PITCH / 12);
|
||||
const gRoofGrp = new THREE.Group();
|
||||
const gRoof = gableRoof({ span: GW, len: GD, pitch: PITCH, y0: GH + 1, matFront: roofW1, matBack: roofW2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
gRoofGrp.add(gRoof.g);
|
||||
gRoofGrp.rotation.y = Math.PI / 2;
|
||||
gRoofGrp.position.set(gx, 0, gz);
|
||||
g.add(gRoofGrp);
|
||||
const pedF = pediment(M, M.sidingWhite, GW, grise, 0, GH + 1, false);
|
||||
pedF.position.set(gx, 0, MD / 2 - 0.01);
|
||||
g.add(pedF);
|
||||
put(g, doorUnit(M, M.doorBlue, 3, 6.6), gx, 4.4, MD / 2 + 0.1);
|
||||
[-6, 6].forEach((z) => put(g, windowUnit(M, 2.8, 4), gx + GW / 2 + 0.1, 6, gz + z, Math.PI / 2));
|
||||
|
||||
/* wing B: small rear nub off the main ridge, the extra facet visible in the survey */
|
||||
const NW = 14, ND = 12, NH = 8;
|
||||
const nx = -MW / 2 + NW / 2 + 3, nz = -MD / 2 - ND / 2 + 1;
|
||||
g.add(box(NW + 0.6, 1, ND + 0.6, M.concrete, nx, 0.5, nz));
|
||||
g.add(box(NW, NH, ND, M.sidingWhite, nx, NH / 2 + 1, nz));
|
||||
const nRoofGrp = new THREE.Group();
|
||||
const nRoof = gableRoof({ span: ND, len: NW, pitch: PITCH, oE: 1, oG: 1, y0: NH + 1, matFront: roofN1, matBack: roofN2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
nRoofGrp.add(nRoof.g);
|
||||
nRoofGrp.position.set(nx, 0, nz);
|
||||
g.add(nRoofGrp);
|
||||
put(g, windowUnit(M, 2.4, 3.4), nx, 5.6, nz - ND / 2 - 0.1, Math.PI);
|
||||
|
||||
/* pool, nestled in the courtyard the wing creates */
|
||||
g.add(box(14, 0.3, 7, M.concrete, gx - 2, -0.05, -10));
|
||||
|
||||
/* porch over the front door */
|
||||
const px = -8;
|
||||
g.add(box(9.5, 0.9, 5.5, M.concrete, px, 0.45, MD / 2 + 2.6));
|
||||
[[-4, 2.2], [4, 2.2], [-4, 4.9], [4, 4.9]].forEach((pp) =>
|
||||
g.add(box(0.55, 8.6, 0.55, M.trim, px + pp[0], 5.2, MD / 2 + pp[1])));
|
||||
const pr = box(11, 0.4, 7, M.porchRoof, px, 10.2, MD / 2 + 2.8);
|
||||
pr.rotation.x = 0.12;
|
||||
g.add(pr);
|
||||
put(g, doorUnit(M, M.doorRed), px, 4.45, MD / 2 + 0.12);
|
||||
|
||||
/* windows */
|
||||
const F = MD / 2 + 0.1;
|
||||
[-16, -8, 0, 8, 16].forEach((x) => put(g, windowUnit(M), x, 15.6, F));
|
||||
[-16, 0, 8, 16].forEach((x) => put(g, windowUnit(M, 3.2, 5), x, 6.4, F));
|
||||
[-14, -4, 6, 16].forEach((x) => put(g, windowUnit(M), x, 15.6, -F));
|
||||
[-14, 4, 14].forEach((x) => put(g, windowUnit(M, 3.2, 5), x, 6.4, -F));
|
||||
[-6, 6].forEach((z) => put(g, windowUnit(M), -MW / 2 - 0.1, 15.6, z, -Math.PI / 2));
|
||||
|
||||
/* grounds */
|
||||
g.add(box(17, 0.14, 30, M.asphalt, gx, 0.08, MD / 2 + 15));
|
||||
g.add(box(3.4, 0.12, 26, M.concrete, px, 0.07, MD / 2 + 13));
|
||||
put(g, mailbox(M), px + 5, 0, 41);
|
||||
put(g, tree(M, 1.15), -33, 0, -15);
|
||||
put(g, tree(M, 0.9), 29, 0, -24);
|
||||
put(g, tree(M, 0.75), -31, 0, 14);
|
||||
[-18, -13, -3, 3, 13, 18].forEach((x, i) => put(g, shrub(M, 1 + (i % 3) * 0.18), x, 0, MD / 2 + 2.2));
|
||||
|
||||
const area = Math.round(roof.area + gRoof.area + nRoof.area);
|
||||
return {
|
||||
group: g,
|
||||
measure: { roofSqFt: area, ridgeLf: Math.round(roof.capLf + gRoof.capLf + nRoof.capLf), facets: 6 },
|
||||
anchors: {
|
||||
spanA: v(-MW / 2, 0.4, MD / 2), spanB: v(MW / 2, 0.4, MD / 2),
|
||||
peakTop: v(-MW / 2, WH + 1 + rise + 0.6, 0), peakBase: v(-MW / 2, 0, 0),
|
||||
pitchAt: v(-MW / 2 - 1.2, roof.eaveY, MD / 2 + 1.5),
|
||||
},
|
||||
hl: {
|
||||
ridge: hlFromSegs(M, [roof.ridgeSeg, [v(gx, gRoof.ridgeY + 0.5, gz - GD / 2 - 1.2), v(gx, gRoof.ridgeY + 0.5, gz + GD / 2 + 1.2)]]),
|
||||
eave: hlFromSegs(M, roof.eaveSegs),
|
||||
roof: roofWash,
|
||||
roofMats: [roofF, roofB, roofW1, roofW2, roofN1, roofN2],
|
||||
damage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* steep 12/12 multi-wing estate with a detached outbuilding, modelled after a client-supplied
|
||||
EagleView survey (12552 Mustang Circle, Forney TX): 6,490 sq ft across 37 facets on the real
|
||||
roof, simplified here to a cross-wing main house plus a separate garage while keeping the
|
||||
signature 12/12 pitch and split-structure footprint. */
|
||||
export function buildMustang(M) {
|
||||
const g = new THREE.Group();
|
||||
const MW = 32, MD = 24, WH = 13, PITCH = 12;
|
||||
const rise = (MD / 2) * (PITCH / 12);
|
||||
const roofF = M.shingle.clone(), roofB = M.shingle.clone();
|
||||
const roofW1 = M.shingle.clone(), roofW2 = M.shingle.clone();
|
||||
const roofX1 = M.shingle.clone(), roofX2 = M.shingle.clone();
|
||||
const roofN1 = M.shingle.clone(), roofN2 = M.shingle.clone();
|
||||
const roofG1 = M.shingle.clone(), roofG2 = M.shingle.clone();
|
||||
|
||||
g.add(box(MW + 1, 1.4, MD + 1, M.concrete, 0, 0.7, 0));
|
||||
g.add(box(MW, WH, MD, M.sidingWhite, 0, WH / 2 + 1, 0));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, MW / 2, WH + 1, true));
|
||||
g.add(pediment(M, M.sidingWhite, MD, rise, -MW / 2, WH + 1, true));
|
||||
const roof = gableRoof({ span: MD, len: MW, pitch: PITCH, y0: WH + 1, matFront: roofF, matBack: roofB, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
g.add(roof.g);
|
||||
const roofWash = hlRoofWash(M, roof, false);
|
||||
g.add(roofWash);
|
||||
put(g, chimney(M, WH + rise + 6, 10, -MD / 2 - 2));
|
||||
|
||||
/* wing A: front-right, its own steep gable, offset like the real survey's radiating wings */
|
||||
const AW = 18, AD = 20, AH = 11;
|
||||
const ax = MW / 2 + AW / 2 - 0.2, az = MD / 2 - AD / 2 - 2;
|
||||
g.add(box(AW + 0.8, 1.2, AD + 0.8, M.concrete, ax, 0.6, az));
|
||||
g.add(box(AW, AH, AD, M.sidingWhite, ax, AH / 2 + 1, az));
|
||||
const arise = (AW / 2) * (PITCH / 12);
|
||||
const aRoofGrp = new THREE.Group();
|
||||
const aRoof = gableRoof({ span: AW, len: AD, pitch: PITCH, y0: AH + 1, matFront: roofW1, matBack: roofW2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
aRoofGrp.add(aRoof.g);
|
||||
aRoofGrp.rotation.y = Math.PI / 2;
|
||||
aRoofGrp.position.set(ax, 0, az);
|
||||
g.add(aRoofGrp);
|
||||
const pedA = pediment(M, M.sidingWhite, AW, arise, 0, AH + 1, false);
|
||||
pedA.position.set(ax, 0, az + AD / 2 - 0.01);
|
||||
g.add(pedA);
|
||||
[-4, 4].forEach((z) => put(g, windowUnit(M, 2.8, 4), ax + AW / 2 + 0.1, 6, az + z, Math.PI / 2));
|
||||
|
||||
/* wing B: rear-left, mirrored offset, so the roofline radiates like the real survey's cross plan */
|
||||
const BW = 16, BD = 18, BH = 10;
|
||||
const bx = -MW / 2 - BW / 2 + 0.2, bz = -MD / 2 + BD / 2 + 2;
|
||||
g.add(box(BW + 0.8, 1.2, BD + 0.8, M.concrete, bx, 0.6, bz));
|
||||
g.add(box(BW, BH, BD, M.sidingWhite, bx, BH / 2 + 1, bz));
|
||||
const brise = (BW / 2) * (PITCH / 12);
|
||||
const bRoofGrp = new THREE.Group();
|
||||
const bRoof = gableRoof({ span: BW, len: BD, pitch: PITCH, y0: BH + 1, matFront: roofX1, matBack: roofX2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
bRoofGrp.add(bRoof.g);
|
||||
bRoofGrp.rotation.y = Math.PI / 2;
|
||||
bRoofGrp.position.set(bx, 0, bz);
|
||||
g.add(bRoofGrp);
|
||||
const pedB = pediment(M, M.sidingWhite, BW, brise, 0, BH + 1, false);
|
||||
pedB.position.set(bx, 0, bz - BD / 2 + 0.01);
|
||||
pedB.rotation.y = Math.PI;
|
||||
g.add(pedB);
|
||||
[-4, 4].forEach((z) => put(g, windowUnit(M, 2.6, 3.6), bx - BW / 2 - 0.1, 5.8, bz + z, -Math.PI / 2));
|
||||
|
||||
/* rear nub: the extra facet visible in the survey's back roofline */
|
||||
const NW = 12, ND = 11, NH = 8;
|
||||
const nx = 2, nz = -MD / 2 - ND / 2 + 1;
|
||||
g.add(box(NW + 0.6, 1, ND + 0.6, M.concrete, nx, 0.5, nz));
|
||||
g.add(box(NW, NH, ND, M.sidingWhite, nx, NH / 2 + 1, nz));
|
||||
const nRoofGrp = new THREE.Group();
|
||||
const nRoof = gableRoof({ span: ND, len: NW, pitch: PITCH, oE: 1, oG: 1, y0: NH + 1, matFront: roofN1, matBack: roofN2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
nRoofGrp.add(nRoof.g);
|
||||
nRoofGrp.position.set(nx, 0, nz);
|
||||
g.add(nRoofGrp);
|
||||
put(g, windowUnit(M, 2.4, 3.2), nx, 5.4, nz - ND / 2 - 0.1, Math.PI);
|
||||
|
||||
/* front door + small stoop; a full porch roof would clip a pitch this steep */
|
||||
g.add(box(6, 0.7, 4, M.concrete, -6, 0.35, MD / 2 + 2));
|
||||
put(g, doorUnit(M, M.doorRed), -6, 4.45, MD / 2 + 0.12);
|
||||
|
||||
/* windows */
|
||||
const F = MD / 2 + 0.1;
|
||||
[-12, -4, 4, 12].forEach((x) => put(g, windowUnit(M), x, 15.2, F));
|
||||
[-12, 12].forEach((x) => put(g, windowUnit(M, 3.2, 5), x, 6.4, F));
|
||||
[-10, 0, 10].forEach((x) => put(g, windowUnit(M), x, 15.2, -F));
|
||||
|
||||
/* detached garage: a separate outbuilding on a shallow 4/12 pitch, matching the survey's Structure #2 */
|
||||
const GW = 20, GD = 18, GH = 8, GP = 4;
|
||||
const gx = -MW / 2 - 26, gz = 6;
|
||||
const garage = new THREE.Group();
|
||||
garage.add(box(GW + 0.8, 1, GD + 0.8, M.concrete, 0, 0.5, 0));
|
||||
garage.add(box(GW, GH, GD, M.sidingGray, 0, GH / 2 + 0.5, 0));
|
||||
const gRoof = gableRoof({ span: GD, len: GW, pitch: GP, oE: 1.3, oG: 1, y0: GH + 0.5, matFront: roofG1, matBack: roofG2, capMat: M.fascia, fasciaMat: M.fascia });
|
||||
garage.add(gRoof.g);
|
||||
put(garage, doorUnit(M, M.garage, 14, 7), 0, 4.4, GD / 2 + 0.1);
|
||||
garage.position.set(gx, 0, gz);
|
||||
g.add(garage);
|
||||
|
||||
/* grounds */
|
||||
g.add(box(16, 0.14, 34, M.asphalt, gx + 4, 0.08, gz + 20));
|
||||
g.add(box(3, 0.12, 20, M.concrete, -6, 0.07, MD / 2 + 12));
|
||||
put(g, mailbox(M), -3, 0, 38);
|
||||
put(g, tree(M, 1.3), -40, 0, -18);
|
||||
put(g, tree(M, 1), 26, 0, -22);
|
||||
put(g, tree(M, 0.85), -18, 0, 30);
|
||||
[-14, -6, 6, 14].forEach((x, i) => put(g, shrub(M, 1 + (i % 3) * 0.15), x, 0, MD / 2 + 2));
|
||||
|
||||
const area = Math.round(roof.area + aRoof.area + bRoof.area + nRoof.area + gRoof.area);
|
||||
return {
|
||||
group: g,
|
||||
measure: { roofSqFt: area, ridgeLf: Math.round(roof.capLf + aRoof.capLf + bRoof.capLf + nRoof.capLf + gRoof.capLf), facets: 12 },
|
||||
anchors: {
|
||||
spanA: v(-MW / 2, 0.4, MD / 2), spanB: v(MW / 2, 0.4, MD / 2),
|
||||
peakTop: v(-MW / 2, WH + 1 + rise + 0.6, 0), peakBase: v(-MW / 2, 0, 0),
|
||||
pitchAt: v(-MW / 2 - 1.2, roof.eaveY, MD / 2 + 1.5),
|
||||
},
|
||||
hl: {
|
||||
ridge: hlFromSegs(M, [
|
||||
roof.ridgeSeg,
|
||||
[v(ax, aRoof.ridgeY + 0.5, az - AD / 2 - 1.2), v(ax, aRoof.ridgeY + 0.5, az + AD / 2 + 1.2)],
|
||||
[v(bx, bRoof.ridgeY + 0.5, bz - BD / 2 - 1.2), v(bx, bRoof.ridgeY + 0.5, bz + BD / 2 + 1.2)],
|
||||
]),
|
||||
eave: hlFromSegs(M, roof.eaveSegs),
|
||||
roof: roofWash,
|
||||
roofMats: [roofF, roofB, roofW1, roofW2, roofX1, roofX2, roofN1, roofN2, roofG1, roofG2],
|
||||
damage: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import * as THREE from "three";
|
||||
import gsap from "gsap";
|
||||
import { makeMaterials } from "./textures.js";
|
||||
import { buildGable, buildHip, buildStorm, buildEstate, buildMustang } from "./houses.js";
|
||||
|
||||
/*
|
||||
* Hidden-line blueprint renderer.
|
||||
* Faces render as opaque "paper" (near the page canvas colour) so nearer
|
||||
* geometry occludes the linework behind it, exactly like a technical drawing.
|
||||
* Edges are extracted per mesh and drawn in bone ink at three weights.
|
||||
* A pair of opposing clipping planes splits every ink line into "inked"
|
||||
* (behind the scan) and "pencil ghost" (ahead of it), so the LiDAR pass
|
||||
* literally draws the house, and chip swaps replay the same plotter wipe.
|
||||
*/
|
||||
|
||||
const PAPER = 0x11141a;
|
||||
const INK = 0xdfe4ea;
|
||||
const AMBER = 0xf2a83b;
|
||||
|
||||
export function createScene(mount, { reduced = false } = {}) {
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2));
|
||||
renderer.localClippingEnabled = true;
|
||||
renderer.domElement.style.cssText = "width:100%;height:100%;display:block";
|
||||
mount.prepend(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
let W = 880, H = 344;
|
||||
const camera = new THREE.PerspectiveCamera(33, W / H, 1, 500);
|
||||
|
||||
const M = makeMaterials();
|
||||
|
||||
/* ---- ink pipeline ------------------------------------------------------ */
|
||||
const clipInk = new THREE.Plane(new THREE.Vector3(-1, 0, 0), 1e6); /* keeps x < c */
|
||||
const clipGhost = new THREE.Plane(new THREE.Vector3(1, 0, 0), -1e6); /* keeps x > c */
|
||||
const lineMat = (opacity, color = INK, clipped = true) =>
|
||||
new THREE.LineBasicMaterial({
|
||||
color, transparent: true, opacity,
|
||||
clippingPlanes: clipped ? [clipInk] : null,
|
||||
});
|
||||
const inks = { primary: lineMat(0.95), roofBase: null, detail: lineMat(0.52), site: lineMat(0.26) };
|
||||
const ghost = new THREE.LineBasicMaterial({ color: INK, transparent: true, opacity: 0.16, clippingPlanes: [clipGhost] });
|
||||
const inkRed = new THREE.LineBasicMaterial({ color: 0xff6644, transparent: true, opacity: 0.9, clippingPlanes: [clipInk] });
|
||||
const paper = new THREE.MeshBasicMaterial({ color: PAPER, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
|
||||
/* dusk: window glass carries a faint interior warmth without leaving the ink language */
|
||||
const glassGlow = new THREE.MeshBasicMaterial({ color: 0x30260e, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
|
||||
function setScanX(x) { clipInk.constant = x; clipGhost.constant = -x; }
|
||||
setScanX(reduced ? 1e6 : -78);
|
||||
|
||||
/* ---- houses, converted to hidden-line ---------------------------------- */
|
||||
const houses = { gable: buildGable(M), hip: buildHip(M), storm: buildStorm(M), estate: buildEstate(M), mustang: buildMustang(M) };
|
||||
let styleKey = "gable";
|
||||
|
||||
function classify(m, house, inherited) {
|
||||
if (inherited) return inherited;
|
||||
const mat = m.material;
|
||||
if (house.hl.roofMats.includes(mat)) return "roof";
|
||||
if (mat === M.tarp) return "tarp";
|
||||
if (mat === M.trunk || mat === M.foliage || mat === M.foliage2) return "sitewire";
|
||||
if (mat === M.asphalt) return "siteflat";
|
||||
if (mat === M.glass || mat === M.trim || mat === M.doorRed || mat === M.doorBlue || mat === M.garage) return "detail";
|
||||
const h = m.geometry && m.geometry.parameters ? m.geometry.parameters.height : undefined;
|
||||
if (mat === M.concrete) return h !== undefined && h <= 0.35 ? "siteflat" : "primary";
|
||||
if (mat === M.porchRoof) return h !== undefined && h <= 0.25 ? "site" : "primary";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function convert(house) {
|
||||
house.roofLineMats = [];
|
||||
const jobs = [];
|
||||
(function walk(o, inherited) {
|
||||
const tag = o.userData && o.userData.ink ? o.userData.ink : inherited;
|
||||
if (tag === "skip") return;
|
||||
if (o.isMesh) jobs.push([o, tag]);
|
||||
const kids = o.children.slice();
|
||||
for (const c of kids) walk(c, tag);
|
||||
})(house.group, null);
|
||||
for (const [m, tag] of jobs) {
|
||||
if (m.isInstancedMesh) {
|
||||
const im = new THREE.MeshBasicMaterial({ color: 0xff5030 });
|
||||
m.material = im;
|
||||
if (house.hl.damage) { house.hl.damage.mat = im; }
|
||||
continue;
|
||||
}
|
||||
const level = classify(m, house, tag);
|
||||
const eg = new THREE.EdgesGeometry(m.geometry, 30);
|
||||
let mat;
|
||||
if (level === "roof") {
|
||||
mat = lineMat(0.95);
|
||||
house.roofLineMats.push(mat);
|
||||
} else if (level === "tarp") {
|
||||
mat = inkRed;
|
||||
} else {
|
||||
mat = inks[level === "siteflat" || level === "sitewire" ? "site" : level] || inks.primary;
|
||||
}
|
||||
const ln = new THREE.LineSegments(eg, mat);
|
||||
ln.position.copy(m.position);
|
||||
ln.rotation.copy(m.rotation);
|
||||
ln.scale.copy(m.scale);
|
||||
m.parent.add(ln);
|
||||
if (!reduced) {
|
||||
const gl = new THREE.LineSegments(eg, ghost);
|
||||
gl.position.copy(m.position);
|
||||
gl.rotation.copy(m.rotation);
|
||||
gl.scale.copy(m.scale);
|
||||
m.parent.add(gl);
|
||||
}
|
||||
if (level === "siteflat" || level === "sitewire" || level === "tarp") m.visible = false;
|
||||
else if (m.material === M.glass) m.material = glassGlow;
|
||||
else m.material = paper;
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(houses)) {
|
||||
const h = houses[k];
|
||||
h.hl.ridge.userData.ink = "skip";
|
||||
h.hl.eave.userData.ink = "skip";
|
||||
h.group.add(h.hl.ridge, h.hl.eave);
|
||||
convert(h);
|
||||
h.group.visible = k === styleKey;
|
||||
scene.add(h.group);
|
||||
}
|
||||
|
||||
/* ---- site plan linework (scene level, unclipped) ------------------------ */
|
||||
const sitePlan = new THREE.Group();
|
||||
{
|
||||
const gridMat = new THREE.LineBasicMaterial({ color: INK, transparent: true, opacity: 0.06 });
|
||||
const pts = [];
|
||||
const R = 50;
|
||||
for (let g = -R; g <= R; g += 10) {
|
||||
const m = Math.sqrt(Math.max(0, R * R - g * g));
|
||||
pts.push(g, 0, -m, g, 0, m, -m, 0, g, m, 0, g);
|
||||
}
|
||||
const gg = new THREE.BufferGeometry();
|
||||
gg.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pts), 3));
|
||||
sitePlan.add(new THREE.LineSegments(gg, gridMat));
|
||||
|
||||
/* dashed property boundary */
|
||||
const bMat = new THREE.LineDashedMaterial({ color: INK, transparent: true, opacity: 0.3, dashSize: 2.2, gapSize: 2 });
|
||||
const bx = 52, bz = 41;
|
||||
const bGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-bx, 0, -bz), new THREE.Vector3(bx, 0, -bz),
|
||||
new THREE.Vector3(bx, 0, bz), new THREE.Vector3(-bx, 0, bz),
|
||||
]);
|
||||
const bound = new THREE.LineLoop(bGeo, bMat);
|
||||
bound.computeLineDistances();
|
||||
sitePlan.add(bound);
|
||||
|
||||
/* survey crosses at the corners */
|
||||
const cMat = new THREE.LineBasicMaterial({ color: INK, transparent: true, opacity: 0.4 });
|
||||
const cp = [];
|
||||
[[-bx, -bz], [bx, -bz], [bx, bz], [-bx, bz]].forEach(([x, z]) => {
|
||||
cp.push(x - 1.6, 0, z, x + 1.6, 0, z, x, 0, z - 1.6, x, 0, z + 1.6);
|
||||
});
|
||||
const cGeo = new THREE.BufferGeometry();
|
||||
cGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(cp), 3));
|
||||
sitePlan.add(new THREE.LineSegments(cGeo, cMat));
|
||||
|
||||
/* street edges + centre dashes */
|
||||
const sMat = new THREE.LineBasicMaterial({ color: INK, transparent: true, opacity: 0.2 });
|
||||
const sGeo = new THREE.BufferGeometry();
|
||||
sGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
|
||||
-62, 0, 44, 62, 0, 44, -62, 0, 57, 62, 0, 57,
|
||||
]), 3));
|
||||
sitePlan.add(new THREE.LineSegments(sGeo, sMat));
|
||||
const dashes = [];
|
||||
for (let x = -58; x < 58; x += 9) dashes.push(x, 0, 50.5, x + 4, 0, 50.5);
|
||||
const dGeo = new THREE.BufferGeometry();
|
||||
dGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(dashes), 3));
|
||||
sitePlan.add(new THREE.LineSegments(dGeo, sMat));
|
||||
sitePlan.position.y = 0.01;
|
||||
}
|
||||
scene.add(sitePlan);
|
||||
|
||||
/* soft contact blob keeps the mass grounded */
|
||||
const contact = new THREE.Mesh(new THREE.CircleGeometry(40, 48), M.contact);
|
||||
contact.rotation.x = -Math.PI / 2;
|
||||
contact.position.y = 0.005;
|
||||
contact.material.opacity = 0.32;
|
||||
contact.scale.set(1.25, 0.85, 1);
|
||||
scene.add(contact);
|
||||
|
||||
/* ---- scan sheet visuals -------------------------------------------------- */
|
||||
const sheet = new THREE.Mesh(new THREE.PlaneGeometry(140, 44), M.scan);
|
||||
sheet.rotation.y = Math.PI / 2;
|
||||
sheet.position.y = 18;
|
||||
sheet.visible = false;
|
||||
sheet.renderOrder = 20;
|
||||
scene.add(sheet);
|
||||
const scanEdge = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(0.22, 50, 128),
|
||||
new THREE.MeshBasicMaterial({ color: 0xffcf55, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false })
|
||||
);
|
||||
scanEdge.position.y = 22;
|
||||
scanEdge.visible = false;
|
||||
scanEdge.renderOrder = 21;
|
||||
scene.add(scanEdge);
|
||||
|
||||
/* ---- camera orbit -------------------------------------------------------- */
|
||||
const target = new THREE.Vector3(3, 9.5, 0);
|
||||
let az = -0.62, el = 0.27, radius = 96;
|
||||
let vel = 0.00115, dragVel = 0, dragging = false;
|
||||
function placeCamera() {
|
||||
camera.position.set(
|
||||
target.x + radius * Math.cos(el) * Math.sin(az),
|
||||
target.y + radius * Math.sin(el),
|
||||
target.z + radius * Math.cos(el) * Math.cos(az)
|
||||
);
|
||||
camera.lookAt(target);
|
||||
}
|
||||
placeCamera();
|
||||
|
||||
const cvs = renderer.domElement;
|
||||
cvs.style.cursor = "grab";
|
||||
let lastX = 0, lastT = 0;
|
||||
cvs.addEventListener("pointerdown", (e) => {
|
||||
dragging = true;
|
||||
lastX = e.clientX;
|
||||
lastT = performance.now();
|
||||
cvs.setPointerCapture(e.pointerId);
|
||||
cvs.style.cursor = "grabbing";
|
||||
onDrag();
|
||||
});
|
||||
cvs.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const now = performance.now();
|
||||
const dx = e.clientX - lastX;
|
||||
az -= dx * 0.008;
|
||||
dragVel = (dx / Math.max(now - lastT, 1)) * 0.11;
|
||||
lastX = e.clientX;
|
||||
lastT = now;
|
||||
});
|
||||
const up = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
cvs.style.cursor = "grab";
|
||||
vel = Math.max(-0.2, Math.min(0.2, -dragVel));
|
||||
dragVel = 0;
|
||||
};
|
||||
cvs.addEventListener("pointerup", up);
|
||||
cvs.addEventListener("pointercancel", up);
|
||||
let onDrag = () => {};
|
||||
|
||||
/* ---- scan scrub + plotter wipe ------------------------------------------- */
|
||||
let stageT = 0;
|
||||
const swapWipe = { active: false, x: -80 };
|
||||
function scanUpdate() {
|
||||
const t = stageT;
|
||||
const sa = Math.min(1, Math.max(0, (t - 0.03) / 0.05)) * (1 - Math.min(1, Math.max(0, (t - 0.76) / 0.15)));
|
||||
const sp = Math.min(1, Math.max(0, (t - 0.05) / 0.28));
|
||||
const on = sa > 0.01 && sp > 0 && sp < 1;
|
||||
sheet.visible = on;
|
||||
scanEdge.visible = on;
|
||||
if (on) {
|
||||
const x = -78 + 156 * sp;
|
||||
sheet.position.x = x;
|
||||
scanEdge.position.x = x;
|
||||
sheet.material.opacity = 0.16 * sa;
|
||||
scanEdge.material.opacity = 0.5 * sa;
|
||||
}
|
||||
if (reduced) { setScanX(1e6); return; }
|
||||
if (swapWipe.active) setScanX(swapWipe.x);
|
||||
else setScanX(-78 + 156 * sp);
|
||||
}
|
||||
|
||||
/* ---- highlights ----------------------------------------------------------- */
|
||||
function applyHighlight(k) {
|
||||
for (const key of Object.keys(houses)) {
|
||||
const h = houses[key];
|
||||
const on = key === styleKey;
|
||||
h.hl.ridge.visible = on && k === "ridge";
|
||||
h.hl.eave.visible = on && k === "eave";
|
||||
if (h.hl.roof) h.hl.roof.visible = on && k === "roof";
|
||||
(h.roofLineMats || []).forEach((m) => m.color.setHex(on && k === "roof" ? AMBER : INK));
|
||||
if (h.hl.damage) h.hl.damage.mat.color.setHex(on && k === "damage" ? 0xff2612 : 0xff5030);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- loop ------------------------------------------------------------------ */
|
||||
let visible = true;
|
||||
if ("IntersectionObserver" in window) new IntersectionObserver((en) => { visible = en[0].isIntersecting; }).observe(cvs);
|
||||
let frameCb = null;
|
||||
let prev = performance.now();
|
||||
renderer.setAnimationLoop((now) => {
|
||||
const dt = Math.min(now - prev, 50);
|
||||
prev = now;
|
||||
if (!visible) return;
|
||||
if (!reduced && !dragging) {
|
||||
vel += (0.00115 - vel) * 0.02;
|
||||
az += (vel * dt) / 16.7;
|
||||
}
|
||||
placeCamera();
|
||||
scanUpdate();
|
||||
if (frameCb) frameCb();
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
|
||||
return {
|
||||
el: cvs,
|
||||
onFrame(cb) { frameCb = cb; },
|
||||
onDragStart(cb) { onDrag = cb; },
|
||||
resize(w, h) {
|
||||
W = Math.max(2, Math.round(w));
|
||||
H = Math.max(2, Math.round(h));
|
||||
renderer.setSize(W, H, false);
|
||||
camera.aspect = W / H;
|
||||
camera.updateProjectionMatrix();
|
||||
},
|
||||
measure({ style }) {
|
||||
return houses[style]?.measure || houses.gable.measure;
|
||||
},
|
||||
anchors() { return houses[styleKey].anchors; },
|
||||
project(v3) {
|
||||
const p = v3.clone().project(camera);
|
||||
return { x: (p.x * 0.5 + 0.5) * W, y: (-p.y * 0.5 + 0.5) * H, behind: p.z > 1 };
|
||||
},
|
||||
viewSize() { return { w: W, h: H }; },
|
||||
stage(t) { stageT = t; },
|
||||
highlight(k) { applyHighlight(k || null); },
|
||||
/* replay the plotter wipe on the current model (first-load draw-in) */
|
||||
wipe() {
|
||||
if (reduced) return;
|
||||
gsap.killTweensOf(swapWipe);
|
||||
swapWipe.active = true;
|
||||
swapWipe.x = -80;
|
||||
gsap.to(swapWipe, {
|
||||
x: 85, duration: 1.1, ease: "power2.inOut",
|
||||
onComplete() { swapWipe.active = false; },
|
||||
});
|
||||
},
|
||||
set(cfg) {
|
||||
const next = cfg.style && houses[cfg.style] ? cfg.style : "gable";
|
||||
if (next === styleKey) return;
|
||||
houses[styleKey].group.visible = false;
|
||||
styleKey = next;
|
||||
houses[next].group.visible = true;
|
||||
applyHighlight(null);
|
||||
if (reduced) return;
|
||||
/* the new model plots itself in, left to right */
|
||||
gsap.killTweensOf(swapWipe);
|
||||
swapWipe.active = true;
|
||||
swapWipe.x = -80;
|
||||
gsap.to(swapWipe, {
|
||||
x: 85, duration: 0.9, ease: "power2.inOut",
|
||||
onComplete() { swapWipe.active = false; },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
function canvasTex(size, draw, repeatX = 1, repeatY = 1) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = c.height = size;
|
||||
draw(c.getContext("2d"), size);
|
||||
const t = new THREE.CanvasTexture(c);
|
||||
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
||||
t.repeat.set(repeatX, repeatY);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
t.anisotropy = 8;
|
||||
return t;
|
||||
}
|
||||
|
||||
function rand(seed) {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
||||
return s / 0x7fffffff;
|
||||
};
|
||||
}
|
||||
|
||||
/* asphalt shingle courses with staggered tabs */
|
||||
function drawShingles(g, S, base, seed = 7, weather = 0) {
|
||||
const r = rand(seed);
|
||||
g.fillStyle = base;
|
||||
g.fillRect(0, 0, S, S);
|
||||
const course = S / 10;
|
||||
for (let row = 0; row < 10; row++) {
|
||||
const y = row * course;
|
||||
const off = (row % 2) * (course * 1.1);
|
||||
for (let x = -1; x < 8; x++) {
|
||||
const w = course * 2.2;
|
||||
const px = x * w + off;
|
||||
const shade = 0.82 + r() * 0.3 - weather * r() * 0.25;
|
||||
g.fillStyle = `rgba(0,0,0,${Math.max(0, 1 - shade) * 0.9})`;
|
||||
g.fillRect(px, y, w - 1.5, course - 1.5);
|
||||
if (r() < 0.12 * (1 + weather * 3)) {
|
||||
g.fillStyle = `rgba(255,255,255,${0.04 + r() * 0.05})`;
|
||||
g.fillRect(px, y, w - 1.5, course - 1.5);
|
||||
}
|
||||
}
|
||||
g.fillStyle = "rgba(0,0,0,.5)";
|
||||
g.fillRect(0, y + course - 1.5, S, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* hail damage overlay: bruise blotches + torn patches */
|
||||
function drawStormShingles(g, S) {
|
||||
drawShingles(g, S, "#4c4a45", 11, 1);
|
||||
const r = rand(41);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const x = r() * S, y = r() * S, rad = 18 + r() * 46;
|
||||
const gr = g.createRadialGradient(x, y, 2, x, y, rad);
|
||||
gr.addColorStop(0, "rgba(140,60,20,.5)");
|
||||
gr.addColorStop(0.6, "rgba(120,50,25,.22)");
|
||||
gr.addColorStop(1, "rgba(0,0,0,0)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(x - rad, y - rad, rad * 2, rad * 2);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = r() * S * 0.8, y = r() * S * 0.8;
|
||||
g.fillStyle = "rgba(15,14,12,.85)";
|
||||
g.fillRect(x, y, 26 + r() * 30, 12 + r() * 10);
|
||||
}
|
||||
}
|
||||
|
||||
function drawClapboard(g, S, base, shadow) {
|
||||
const board = S / 12;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const y = i * board;
|
||||
const gr = g.createLinearGradient(0, y, 0, y + board);
|
||||
gr.addColorStop(0, base);
|
||||
gr.addColorStop(0.85, base);
|
||||
gr.addColorStop(1, shadow);
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, y, S, board);
|
||||
}
|
||||
const r = rand(5);
|
||||
g.fillStyle = "rgba(0,0,0,.05)";
|
||||
for (let i = 0; i < 40; i++) g.fillRect(r() * S, r() * S, 1.5, 4 + r() * 8);
|
||||
}
|
||||
|
||||
function drawBrick(g, S) {
|
||||
g.fillStyle = "#8a8078";
|
||||
g.fillRect(0, 0, S, S);
|
||||
const bh = S / 10, bw = S / 4;
|
||||
const r = rand(23);
|
||||
for (let row = 0; row < 10; row++) {
|
||||
const off = (row % 2) * bw * 0.5;
|
||||
for (let col = -1; col < 5; col++) {
|
||||
const tone = 96 + r() * 46;
|
||||
g.fillStyle = `rgb(${tone + 20},${tone * 0.62},${tone * 0.5})`;
|
||||
g.fillRect(col * bw + off + 2, row * bh + 2, bw - 4, bh - 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawConcrete(g, S, tone = 168) {
|
||||
g.fillStyle = `rgb(${tone},${tone},${tone - 4})`;
|
||||
g.fillRect(0, 0, S, S);
|
||||
const r = rand(3);
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const v = r();
|
||||
g.fillStyle = v > 0.5 ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.06)";
|
||||
g.fillRect(r() * S, r() * S, 1 + r() * 2, 1 + r() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
function drawAsphalt(g, S) {
|
||||
g.fillStyle = "#292b2a";
|
||||
g.fillRect(0, 0, S, S);
|
||||
const r = rand(17);
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
const v = r();
|
||||
g.fillStyle = v > 0.55 ? "rgba(255,255,255,.05)" : "rgba(0,0,0,.12)";
|
||||
g.fillRect(r() * S, r() * S, 1 + r() * 2, 1 + r() * 2);
|
||||
}
|
||||
}
|
||||
|
||||
/* grass detail and the radial alpha falloff baked into ONE texture (repeat 1x1),
|
||||
because standard materials share the map's UV transform across texture slots */
|
||||
function drawGrassDisc(g, S) {
|
||||
g.fillStyle = "#242d1b";
|
||||
g.fillRect(0, 0, S, S);
|
||||
const r = rand(9);
|
||||
for (let i = 0; i < 26000; i++) {
|
||||
const v = r();
|
||||
const c = v > 0.66 ? "rgba(58,74,40,.5)" : v > 0.33 ? "rgba(34,44,26,.55)" : "rgba(20,26,16,.5)";
|
||||
g.fillStyle = c;
|
||||
g.fillRect(r() * S, r() * S, 1 + r() * 2.4, 1 + r() * 2);
|
||||
}
|
||||
/* mow lines */
|
||||
g.fillStyle = "rgba(255,255,255,.016)";
|
||||
for (let i = 0; i < 14; i++) g.fillRect(0, i * (S / 14), S, S / 28);
|
||||
/* carve the radial fade into the alpha channel */
|
||||
g.globalCompositeOperation = "destination-in";
|
||||
const gr = g.createRadialGradient(S / 2, S / 2, S * 0.18, S / 2, S / 2, S * 0.5);
|
||||
gr.addColorStop(0, "rgba(0,0,0,1)");
|
||||
gr.addColorStop(0.5, "rgba(0,0,0,1)");
|
||||
gr.addColorStop(0.78, "rgba(0,0,0,.45)");
|
||||
gr.addColorStop(0.98, "rgba(0,0,0,0)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, S, S);
|
||||
g.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
/* street strip: asphalt with lane dashes, alpha-faded at both ends */
|
||||
export function drawRoad(g, S) {
|
||||
drawAsphalt(g, S);
|
||||
g.fillStyle = "rgba(210,200,160,.5)";
|
||||
for (let i = 0; i < 9; i++) g.fillRect(i * (S / 9) + S / 36, S * 0.485, S / 18, S * 0.03);
|
||||
g.fillStyle = "rgba(220,220,220,.28)";
|
||||
g.fillRect(0, S * 0.06, S, S * 0.016);
|
||||
g.fillRect(0, S * 0.924, S, S * 0.016);
|
||||
g.globalCompositeOperation = "destination-in";
|
||||
const gr = g.createLinearGradient(0, 0, S, 0);
|
||||
gr.addColorStop(0, "rgba(0,0,0,0)");
|
||||
gr.addColorStop(0.18, "rgba(0,0,0,1)");
|
||||
gr.addColorStop(0.82, "rgba(0,0,0,1)");
|
||||
gr.addColorStop(1, "rgba(0,0,0,0)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, S, S);
|
||||
g.globalCompositeOperation = "source-over";
|
||||
}
|
||||
|
||||
/* soft dark contact blob that grounds whatever stands on it */
|
||||
export function drawContact(g, S) {
|
||||
const gr = g.createRadialGradient(S / 2, S / 2, S * 0.05, S / 2, S / 2, S * 0.5);
|
||||
gr.addColorStop(0, "rgba(0,0,0,.62)");
|
||||
gr.addColorStop(0.55, "rgba(0,0,0,.34)");
|
||||
gr.addColorStop(1, "rgba(0,0,0,0)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, S, S);
|
||||
}
|
||||
|
||||
function drawGaragePanels(g, S) {
|
||||
g.fillStyle = "#d8d6d0";
|
||||
g.fillRect(0, 0, S, S);
|
||||
const ph = S / 4;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = i * ph;
|
||||
const gr = g.createLinearGradient(0, y, 0, y + ph);
|
||||
gr.addColorStop(0, "rgba(0,0,0,.22)");
|
||||
gr.addColorStop(0.14, "rgba(255,255,255,.16)");
|
||||
gr.addColorStop(0.85, "rgba(0,0,0,.05)");
|
||||
gr.addColorStop(1, "rgba(0,0,0,.34)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, y, S, ph);
|
||||
}
|
||||
}
|
||||
|
||||
/* soft elliptical glow for the LiDAR sheet: fades before every plane edge */
|
||||
function drawScanBand(g, S) {
|
||||
const gr = g.createRadialGradient(S / 2, S / 2, S * 0.05, S / 2, S / 2, S * 0.5);
|
||||
gr.addColorStop(0, "rgba(255,214,90,.85)");
|
||||
gr.addColorStop(0.45, "rgba(255,184,0,.34)");
|
||||
gr.addColorStop(1, "rgba(255,184,0,0)");
|
||||
g.fillStyle = gr;
|
||||
g.fillRect(0, 0, S, S);
|
||||
}
|
||||
|
||||
export function makeMaterials() {
|
||||
const std = (o) => new THREE.MeshStandardMaterial(o);
|
||||
const shingleTex = canvasTex(256, (g, s) => drawShingles(g, s, "#2b2e32", 7), 6, 3);
|
||||
const shingleWarmTex = canvasTex(256, (g, s) => drawShingles(g, s, "#352e26", 19), 6, 3);
|
||||
const stormTex = canvasTex(512, (g, s) => drawStormShingles(g, s), 3, 1.6);
|
||||
return {
|
||||
shingle: std({ map: shingleTex, roughness: 0.92, metalness: 0.02 }),
|
||||
shingleWarm: std({ map: shingleWarmTex, roughness: 0.92, metalness: 0.02 }),
|
||||
shingleStorm: std({ map: stormTex, roughness: 0.95, metalness: 0.02 }),
|
||||
sidingWhite: std({ map: canvasTex(256, (g, s) => drawClapboard(g, s, "#c9c6bd", "#8f8d85"), 3, 3), roughness: 0.85 }),
|
||||
sidingBeige: std({ map: canvasTex(256, (g, s) => drawClapboard(g, s, "#b3a58c", "#7d7360"), 3, 3), roughness: 0.85 }),
|
||||
sidingGray: std({ map: canvasTex(256, (g, s) => drawClapboard(g, s, "#9aa0a2", "#6a6f71"), 3, 3), roughness: 0.85 }),
|
||||
trim: std({ color: 0xe8e6df, roughness: 0.6 }),
|
||||
fascia: std({ color: 0xdad7cf, roughness: 0.65 }),
|
||||
doorRed: std({ color: 0x7e2f26, roughness: 0.5 }),
|
||||
doorBlue: std({ color: 0x2c3f4d, roughness: 0.5 }),
|
||||
glass: std({ color: 0x0e1a22, roughness: 0.08, metalness: 0.85, envMapIntensity: 1.4 }),
|
||||
garage: std({ map: canvasTex(256, drawGaragePanels, 1, 1), roughness: 0.6 }),
|
||||
brick: std({ map: canvasTex(256, drawBrick, 1.4, 2.4), roughness: 0.95 }),
|
||||
concrete: std({ map: canvasTex(256, (g, s) => drawConcrete(g, s), 2, 2), roughness: 0.95 }),
|
||||
asphalt: std({ map: canvasTex(256, drawAsphalt, 2, 3), roughness: 0.98 }),
|
||||
grass: std({
|
||||
map: canvasTex(2048, drawGrassDisc),
|
||||
transparent: true,
|
||||
roughness: 1,
|
||||
depthWrite: false,
|
||||
}),
|
||||
soil: std({ color: 0x2b2822, roughness: 1 }),
|
||||
trunk: std({ color: 0x3c2f21, roughness: 1 }),
|
||||
foliage: std({ color: 0x33431f, roughness: 1, flatShading: true }),
|
||||
foliage2: std({ color: 0x415229, roughness: 1, flatShading: true }),
|
||||
contact: new THREE.MeshBasicMaterial({
|
||||
map: canvasTex(512, drawContact),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
}),
|
||||
road: std({ map: canvasTex(512, drawRoad), transparent: true, roughness: 0.98, depthWrite: false }),
|
||||
tarp: std({ color: 0x27506e, roughness: 0.55, side: THREE.DoubleSide }),
|
||||
porchRoof: std({ color: 0x3a3d40, roughness: 0.7, metalness: 0.25 }),
|
||||
hlAmber: new THREE.MeshBasicMaterial({ color: 0xffb800, transparent: true, opacity: 0.95 }),
|
||||
hlFace: new THREE.MeshBasicMaterial({ color: 0xffc233, transparent: true, opacity: 0.62, side: THREE.DoubleSide, depthWrite: false }),
|
||||
impact: std({ color: 0xff5533, emissive: 0xd23c14, emissiveIntensity: 0.9, roughness: 0.6 }),
|
||||
scan: new THREE.MeshBasicMaterial({
|
||||
map: canvasTex(256, drawScanBand),
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/* alpha-fade note: the grass disc uses alphaMap + depthWrite:false so the lawn
|
||||
dissolves radially into the page background instead of ending in a hard edge */
|
||||
+1777
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user