fix: [rd] 방화셔터 slat roll 시각화 개선

- 모터 Y 위치 수정 (스프로켓 겹침 해소)
- slat roll 색상/형태 개선 (양쪽 디스크, 나선형 표면 라인)
- 재빌드 후 토글 visibility 동기화 추가
This commit is contained in:
김보곤
2026-03-09 07:52:19 +09:00
parent b3cd1ffebc
commit 3ad8e24ac3

View File

@@ -1039,165 +1039,163 @@ function fs3dBuild() {
const boxLine = new THREE.LineSegments(boxEdges, new THREE.LineBasicMaterial({ color: 0x94a3b8 }));
meshes.case.add(boxLine);
// === SHAFT ASSEMBLY (brackets + 환봉 + shaft + 복주머니 + motor) ===
// === SHAFT ASSEMBLY (양쪽 브라켓 관통, 슬랫 폭과 동일) ===
const shaftY = H + b.height * 0.5; // 샤프트 중심 Y
const shaftMat = new THREE.MeshStandardMaterial({ color: 0x64748b, metalness: 0.6, roughness: 0.3 });
const bracketMat = new THREE.MeshStandardMaterial({ color: 0x4b5563, metalness: 0.5, roughness: 0.4 });
meshes.shaft = new THREE.Group();
// Bracket dimensions
const motorDir = b.motorSide === 'right' ? 1 : -1;
const nonMotorSide = -motorDir;
// 비모터측 브라켓 (작은 브라켓)
const bkW = b.bracketW; // 두께 (X방향)
const bkH = b.height * 0.7; // 높이 (Y방향)
const bkD = b.depth * 0.6; // 깊이 (Z방향)
const motorDir = b.motorSide === 'right' ? 1 : -1;
// Inner edge of brackets (where 환봉/복주머니 connects)
const bracketInnerX = W1 / 2 - bkW; // bracket inner face X
const roundBarDia = 40; // 환봉 직경
const couplingDia = 80; // 복주머니 직경
const couplingLen = 80; // 복주머니 길이
// 모터측 브라켓 (도면: 380×180mm, 체인/스프로켓 수용)
const motorBkW = 380; // 모터측 브라켓 폭 (X방향)
const motorBkH = 180; // 모터측 브라켓 높이 (Y방향)
const motorBkD = 18; // 모터측 브라켓 두께 (Z방향, 철판)
const shaftFromInner = 90; // 브라켓 내면에서 샤프트 중심까지 거리 (도면 기준)
// 환봉 길이 = bracket inner → shaft end gap
const roundBarLen = 60;
const nonMotorSide = -motorDir; // 모터 반대쪽
// 주축 길이 = 비모터측(bracket+환봉) + 모터측(복주머니) 제외
const mainShaftLen = W1 - bkW - roundBarLen - couplingLen;
// 주축: 양쪽 브라켓 외면까지 (돌출 없음)
const mainShaftLen = W1;
const msCenterX = 0;
// --- Non-motor side Bracket (모터 반대쪽만 브라켓 존재) ---
// --- Non-motor side Bracket ---
const bkGeo = new THREE.BoxGeometry(bkW, bkH, bkD);
const bkMesh = new THREE.Mesh(bkGeo, bracketMat);
bkMesh.position.set(nonMotorSide * (W1 / 2 - bkW / 2), 0, 0);
meshes.shaft.add(bkMesh);
// --- Non-motor side: 환봉 (round bar, thin cylinder) ---
const rbGeo = new THREE.CylinderGeometry(roundBarDia / 2, roundBarDia / 2, roundBarLen, 16);
rbGeo.rotateZ(Math.PI / 2);
const rbMesh = new THREE.Mesh(rbGeo, shaftMat);
const rbCenterX = nonMotorSide * (W1 / 2 - bkW - roundBarLen / 2);
rbMesh.position.set(rbCenterX, 0, 0);
meshes.shaft.add(rbMesh);
// --- Main Shaft (center cylinder) ---
// --- Main Shaft (양쪽 브라켓 관통, 전체 폭) ---
const msGeo = new THREE.CylinderGeometry(b.shaftDia / 2, b.shaftDia / 2, mainShaftLen, 32);
msGeo.rotateZ(Math.PI / 2);
const msMesh = new THREE.Mesh(msGeo, shaftMat);
// 주축 중심: 환봉쪽 끝 + mainShaftLen/2 쪽으로 이동
const msStartX = nonMotorSide * (W1 / 2 - bkW - roundBarLen);
const msCenterX = msStartX - nonMotorSide * mainShaftLen / 2;
msMesh.position.set(msCenterX, 0, 0);
meshes.shaft.add(msMesh);
// --- Motor side: 복주머니+플랜지는 모터 그룹에 추가 (아래 모터 섹션에서 생성) ---
// cpCenterX, couplingDia 등은 모터 섹션에서 사용
const cpCenterX = motorDir * (W1 / 2 - couplingLen / 2);
meshes.shaft.position.set(0, shaftY, 0);
scene.add(meshes.shaft);
// === MOTOR (DH-150K/300K 스타일: 복주머니 + 본체 + 기어박스 + 베이스 + 다리) ===
// === MOTOR (체인 구동: 브라켓+스프로켓+체인+모터 — 모두 셔터박스 안쪽) ===
meshes.motor = new THREE.Group();
const motorR = b.shaftDia * 0.6; // 모터 반지름 (샤프트 1.2배)
const metalMat = new THREE.MeshStandardMaterial({ color: 0x94a3b8, metalness: 0.6, roughness: 0.3 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x374151, metalness: 0.5, roughness: 0.4 });
const blueMat = new THREE.MeshStandardMaterial({ color: 0x2563eb, metalness: 0.4, roughness: 0.4 });
const chainMat = new THREE.MeshStandardMaterial({ color: 0xdc2626, metalness: 0.3, roughness: 0.5 });
// 0) 복주머니 + 플랜지 (모터 그룹 소속 — 모터 숨기면 함께 숨김)
const cpGeo = new THREE.CylinderGeometry(couplingDia / 2, couplingDia / 2, couplingLen, 24);
cpGeo.rotateZ(Math.PI / 2);
const cpMat = new THREE.MeshStandardMaterial({ color: 0x78716c, metalness: 0.7, roughness: 0.2 });
const cpMesh = new THREE.Mesh(cpGeo, cpMat);
cpMesh.position.set(cpCenterX, 0, 0);
meshes.motor.add(cpMesh);
const flangeGeo = new THREE.CylinderGeometry(couplingDia / 2 + 10, couplingDia / 2 + 10, 6, 24);
flangeGeo.rotateZ(Math.PI / 2);
const flangeMat = new THREE.MeshStandardMaterial({ color: 0x57534e, metalness: 0.8, roughness: 0.2 });
const flange = new THREE.Mesh(flangeGeo, flangeMat);
flange.position.set(motorDir * (W1 / 2 - 3), 0, 0);
meshes.motor.add(flange);
// --- 1) 모터측 브라켓 (큰 브라켓, 벽면에 부착 — YZ 평면 판) ---
// 브라켓은 벽면(W1/2)에 부착, 안쪽(샤프트 방향)으로 스프로켓/모터 장착
const motorBkThick = motorBkD; // 브라켓 판 두께 (X방향) = 18mm
// 브라켓 판: 벽면에 부착 (외면=W1/2, 내면=W1/2-두께)
const motorBkCX = motorDir * (W1 / 2 - motorBkThick / 2);
const motorBkGeo = new THREE.BoxGeometry(motorBkThick, motorBkH, b.depth * 0.8);
const motorBk = new THREE.Mesh(motorBkGeo, bracketMat);
motorBk.position.set(motorBkCX, 0, 0);
meshes.motor.add(motorBk);
// 모터 위치: 브라켓 안쪽, 복주머니 옆
const motorBodyLen = motorR * 2.5; // 본체 길이
const gearBoxLen = motorR * 1.2; // 기어박스 길이
const totalMotorLen = motorBodyLen + gearBoxLen;
const motorStartX = motorDir * (W1 / 2 - couplingLen); // 복주머니 끝 (모터측 브라켓 없음)
const motorMidX = motorStartX - motorDir * totalMotorLen / 2; // 모터 전체 중심
// --- 2) 샤프트 스프로켓 (ø260, 브라켓 안쪽 면에 장착) ---
const shaftSprocketR = 130; // ø260 / 2 (도면 기준)
const sprocketThick = 12;
// 스프로켓: 브라켓 내면에서 안쪽(센터방향)으로 돌출
const sprocketFaceX = motorDir * (W1 / 2 - motorBkThick);
const shaftSprocketX = sprocketFaceX - motorDir * sprocketThick / 2;
const sSpGeo = new THREE.CylinderGeometry(shaftSprocketR, shaftSprocketR, sprocketThick, 36);
sSpGeo.rotateZ(Math.PI / 2);
const sSpMesh = new THREE.Mesh(sSpGeo, darkMat);
sSpMesh.position.set(shaftSprocketX, 0, 0);
meshes.motor.add(sSpMesh);
// 스프로켓 허브
const hubGeo = new THREE.CylinderGeometry(b.shaftDia * 0.7, b.shaftDia * 0.7, sprocketThick + 6, 16);
hubGeo.rotateZ(Math.PI / 2);
const hub = new THREE.Mesh(hubGeo, metalMat);
hub.position.set(shaftSprocketX, 0, 0);
meshes.motor.add(hub);
// 1) 모터 본체 (은색 원통 — 메인 실린더)
// --- 3) 모터 (브라켓 안쪽, 샤프트 아래에 위치 — 축이 샤프트와 평행) ---
const motorR = b.shaftDia * 0.45;
const motorBodyLen = motorR * 3;
const motorSprocketR = shaftSprocketR * 0.35;
// 모터 Y: 샤프트 아래 (큰 스프로켓 + 작은 스프로켓 + 간격)
const motorY = -(shaftSprocketR + motorSprocketR + 20);
// 모터 X: 브라켓 안쪽 (샤프트와 같은 방향, 셔터박스 내부)
const motorCX = sprocketFaceX - motorDir * (motorBodyLen / 2 + 20);
// 모터 본체 (은색 원통, 축=X=샤프트와 평행)
const bodyGeo = new THREE.CylinderGeometry(motorR, motorR, motorBodyLen, 24);
bodyGeo.rotateZ(Math.PI / 2);
const body = new THREE.Mesh(bodyGeo, metalMat);
const bodyCX = motorStartX - motorDir * (gearBoxLen + motorBodyLen / 2);
body.position.set(bodyCX, 0, 0);
body.position.set(motorCX, motorY, 0);
meshes.motor.add(body);
// 2) 상단 리브 (본체 위의 냉각 핀 3줄)
// 모터 후면 마감판 (벽 반대쪽 = 센터쪽)
const ecGeo = new THREE.CylinderGeometry(motorR + 3, motorR + 3, 4, 24);
ecGeo.rotateZ(Math.PI / 2);
const ec = new THREE.Mesh(ecGeo, darkMat);
ec.position.set(motorCX - motorDir * (motorBodyLen / 2 + 2), motorY, 0);
meshes.motor.add(ec);
// 모터 상단 리브 (냉각 핀)
for (let i = 0; i < 3; i++) {
const ribGeo = new THREE.BoxGeometry(motorBodyLen * 0.7, 4, motorR * 0.15);
const ribGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 3, motorR * 0.12);
const rib = new THREE.Mesh(ribGeo, darkMat);
const ribZ = (i - 1) * motorR * 0.5;
rib.position.set(bodyCX, motorR + 2, ribZ);
rib.position.set(motorCX, motorY + motorR + 1.5, (i - 1) * motorR * 0.4);
meshes.motor.add(rib);
}
// 3) 기어박스 (파란색, 본체보다 살짝 넓은 박스 — 복주머니 쪽)
const gearR = motorR * 1.15;
const gearGeo = new THREE.CylinderGeometry(gearR, gearR, gearBoxLen, 24);
gearGeo.rotateZ(Math.PI / 2);
const gear = new THREE.Mesh(gearGeo, blueMat);
const gearCX = motorStartX - motorDir * (gearBoxLen / 2);
gear.position.set(gearCX, 0, 0);
meshes.motor.add(gear);
// 모터 마운팅 플레이트
const mMountGeo = new THREE.BoxGeometry(motorBodyLen * 0.6, 5, motorR * 1.4);
const mMount = new THREE.Mesh(mMountGeo, darkMat);
mMount.position.set(motorCX, motorY - motorR - 2.5, 0);
meshes.motor.add(mMount);
// 4) 기어박스 전면 플랜지 (복주머니 결합면)
const gfGeo = new THREE.CylinderGeometry(gearR + 8, gearR + 8, 5, 24);
gfGeo.rotateZ(Math.PI / 2);
const gf = new THREE.Mesh(gfGeo, darkMat);
gf.position.set(motorStartX - motorDir * 2.5, 0, 0);
meshes.motor.add(gf);
// 모터 출력 스프로켓 (작은 것, 샤프트 스프로켓과 같은 X면)
const mSpGeo = new THREE.CylinderGeometry(motorSprocketR, motorSprocketR, sprocketThick, 20);
mSpGeo.rotateZ(Math.PI / 2);
const mSpMesh = new THREE.Mesh(mSpGeo, darkMat);
mSpMesh.position.set(shaftSprocketX, motorY, 0);
meshes.motor.add(mSpMesh);
// 5) 후면 마감판 (본체 뒤쪽)
const ecGeo = new THREE.CylinderGeometry(motorR + 3, motorR + 3, 6, 24);
ecGeo.rotateZ(Math.PI / 2);
const ec = new THREE.Mesh(ecGeo, darkMat);
ec.position.set(bodyCX - motorDir * (motorBodyLen / 2 + 3), 0, 0);
meshes.motor.add(ec);
// 6) 마운팅 베이스 (브라켓 위에 안착하는 플레이트)
const baseW = totalMotorLen * 0.85;
const baseH = 6;
const baseD = motorR * 1.6;
const baseGeo = new THREE.BoxGeometry(baseW, baseH, baseD);
const basePlate = new THREE.Mesh(baseGeo, darkMat);
const baseY = -motorR - baseH / 2;
basePlate.position.set(motorMidX, baseY, 0);
meshes.motor.add(basePlate);
// 7) 마운팅 다리 (베이스 → 브라켓까지 연결, 3개)
const legH = bkH / 2 - motorR - baseH; // 베이스 하단 ~ 브라켓 상면
if (legH > 0) {
const legGeo = new THREE.BoxGeometry(10, legH, 20);
const legOffsets = [-baseW * 0.35, 0, baseW * 0.35];
legOffsets.forEach(dx => {
const leg = new THREE.Mesh(legGeo, darkMat);
leg.position.set(motorMidX + motorDir * dx, baseY - baseH / 2 - legH / 2, 0);
meshes.motor.add(leg);
});
// --- 4) 체인 (RS #40, 두 스프로켓 연결 — YZ 평면 루프) ---
const sSpY = 0; // 샤프트 스프로켓 Y
const mSpY = motorY; // 모터 스프로켓 Y
const dist = Math.abs(sSpY - mSpY);
// 체인 경로: 큰 스프로켓 상부 → 좌측 직선 → 작은 스프로켓 하부 → 우측 직선
const chainPts = [];
const seg = 16;
// 큰 스프로켓 상부 반원
for (let i = 0; i <= seg; i++) {
const a = Math.PI / 2 + (i / seg) * Math.PI;
chainPts.push(new THREE.Vector3(0, sSpY + Math.sin(a) * shaftSprocketR, Math.cos(a) * shaftSprocketR));
}
// 좌측 직선 (큰 → 작은)
chainPts.push(new THREE.Vector3(0, mSpY + motorSprocketR, -shaftSprocketR));
// 작은 스프로켓 하부 반원 (근사: 직선이 접선)
for (let i = 0; i <= seg / 2; i++) {
const a = Math.PI / 2 - (i / (seg / 2)) * Math.PI;
chainPts.push(new THREE.Vector3(0, mSpY + Math.sin(a) * motorSprocketR, mSpY < 0 ? Math.cos(a) * motorSprocketR : -Math.cos(a) * motorSprocketR));
}
// 우측 직선 (작은 → 큰)
chainPts.push(new THREE.Vector3(0, sSpY - shaftSprocketR, shaftSprocketR));
chainPts.push(chainPts[0].clone());
// 8) 출력축 (기어박스 → 복주머니 방향)
const outR = b.shaftDia * 0.2;
const outLen = 30;
const outGeo = new THREE.CylinderGeometry(outR, outR, outLen, 12);
outGeo.rotateZ(Math.PI / 2);
const outShaft = new THREE.Mesh(outGeo, metalMat);
outShaft.position.set(motorStartX + motorDir * (outLen / 2), 0, 0);
meshes.motor.add(outShaft);
const chainGeo = new THREE.BufferGeometry().setFromPoints(chainPts);
const chainLine = new THREE.Line(chainGeo, new THREE.LineBasicMaterial({ color: 0xdc2626, linewidth: 2 }));
chainLine.position.x = shaftSprocketX;
meshes.motor.add(chainLine);
// 9) 모터측 브라켓 (모터가 안착되는 브라켓 — 모터 그룹에 포함)
const motorBkGeo = new THREE.BoxGeometry(bkW, bkH, bkD);
const motorBk = new THREE.Mesh(motorBkGeo, bracketMat);
motorBk.position.set(motorDir * (W1 / 2 - bkW / 2), 0, 0);
meshes.motor.add(motorBk);
// 체인 직선 부분 (빨간 박스로 두께감)
const chainStraightLen = dist - shaftSprocketR + motorSprocketR;
if (chainStraightLen > 0) {
const csGeo = new THREE.BoxGeometry(6, chainStraightLen, 4);
const csLeft = new THREE.Mesh(csGeo, chainMat);
csLeft.position.set(shaftSprocketX, (sSpY + mSpY) / 2, -shaftSprocketR);
meshes.motor.add(csLeft);
const csRight = new THREE.Mesh(csGeo, chainMat);
csRight.position.set(shaftSprocketX, (sSpY + mSpY) / 2, shaftSprocketR);
meshes.motor.add(csRight);
}
meshes.motor.position.set(0, shaftY, 0);
scene.add(meshes.motor);
@@ -1296,50 +1294,101 @@ function fs3dBuild() {
meshes.slats.add(lineGroup);
}
// === SLAT ROLL (샤프트에 감긴 슬랫) ===
const rolledH = H - shutterH; // 올라간(감긴) 높이
// === SLAT ROLL (샤프트에 감긴 슬랫 — 어느 각도에서도 잘 보이도록) ===
const rolledH = H - shutterH;
if (rolledH > 0) {
// 철재: ㄷ자 슬랫(72mm 폭) 감길 때 두께 ~10mm / 스크린: ~1mm
const wrapThick = S.productType === 'steel' ? 10 : 1;
const shaftR = b.shaftDia / 2;
// 감긴 바퀴 수 = 감긴 높이 / (샤프트 둘레 + 누적 두께 보정)
// 간략 계산: 감긴 총 두께 = sqrt(rolledH * wrapThick / π)
const rollThick = Math.sqrt(rolledH * wrapThick / Math.PI);
const rollOuterR = shaftR + rollThick;
// 롤 길이 = 주축 길이 (브라켓 사이)
const rollLen = mainShaftLen;
const rollLen = W - 40; // 샤프트보다 약간 짧게 (양쪽 20mm씩)
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 32);
meshes.slatRoll = new THREE.Group();
// 메인 롤 실린더
const rollGeo = new THREE.CylinderGeometry(rollOuterR, rollOuterR, rollLen, 48);
rollGeo.rotateZ(Math.PI / 2);
const rollColor = S.productType === 'steel' ? 0xC9B89A : 0xc084fc;
const rollMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0x78716c : 0xa78bfa,
metalness: S.productType === 'steel' ? 0.3 : 0,
roughness: S.productType === 'steel' ? 0.6 : 0.8,
color: rollColor,
metalness: S.productType === 'steel' ? 0.25 : 0,
roughness: S.productType === 'steel' ? 0.55 : 0.8,
transparent: true,
opacity: 0.85,
opacity: 0.92,
});
meshes.slatRoll = new THREE.Mesh(rollGeo, rollMat);
meshes.slatRoll.position.set(msCenterX, shaftY, 0);
scene.add(meshes.slatRoll);
const rollMesh = new THREE.Mesh(rollGeo, rollMat);
meshes.slatRoll.add(rollMesh);
// 철재: 감긴 ㄷ자 슬랫 골 표현 (나선형 링 라인)
if (S.productType === 'steel' && rollThick > 10) {
// 양쪽 끝 디스크 (측면에서 롤 두께 확인 가능)
const discMat = new THREE.MeshStandardMaterial({
color: S.productType === 'steel' ? 0xA89070 : 0x9b6dff,
metalness: 0.3, roughness: 0.5, side: THREE.DoubleSide,
});
const discShape = new THREE.Shape();
discShape.absarc(0, 0, rollOuterR, 0, Math.PI * 2, false);
const innerHole = new THREE.Path();
innerHole.absarc(0, 0, shaftR + 1, 0, Math.PI * 2, true);
discShape.holes.push(innerHole);
const discGeo = new THREE.ShapeGeometry(discShape, 32);
// 좌측 디스크
const discL = new THREE.Mesh(discGeo, discMat);
discL.rotation.y = Math.PI / 2;
discL.position.set(-rollLen / 2, 0, 0);
meshes.slatRoll.add(discL);
// 우측 디스크
const discR = new THREE.Mesh(discGeo.clone(), discMat);
discR.rotation.y = Math.PI / 2;
discR.position.set(rollLen / 2, 0, 0);
meshes.slatRoll.add(discR);
// 감긴 슬랫 골 표현 (나선형 라인 — 여러 위치에서 반복)
if (rollThick > 3) {
const ringGroup = new THREE.Group();
const ringCount = Math.min(Math.floor(rollThick / (wrapThick * 0.15)), 12);
for (let i = 1; i <= ringCount; i++) {
const r = shaftR + (rollThick * i / (ringCount + 1));
const pts = [];
for (let a = 0; a <= 64; a++) {
const angle = (a / 64) * Math.PI * 2;
pts.push(new THREE.Vector3(0, Math.sin(angle) * r, Math.cos(angle) * r));
const ringCount = Math.min(Math.ceil(rollThick / (wrapThick * 0.12)), 15);
const ringPositions = [-rollLen * 0.4, -rollLen * 0.15, rollLen * 0.15, rollLen * 0.4];
const ringLineMat = new THREE.LineBasicMaterial({
color: S.productType === 'steel' ? 0x8B7355 : 0x7c4dff,
opacity: 0.6, transparent: true
});
for (const xPos of ringPositions) {
for (let i = 1; i <= ringCount; i++) {
const r = shaftR + (rollThick * i / (ringCount + 1));
const pts = [];
for (let a = 0; a <= 64; a++) {
const angle = (a / 64) * Math.PI * 2;
pts.push(new THREE.Vector3(0, Math.sin(angle) * r, Math.cos(angle) * r));
}
const ringGeo = new THREE.BufferGeometry().setFromPoints(pts);
const ring = new THREE.Line(ringGeo, ringLineMat);
ring.position.x = xPos;
ringGroup.add(ring);
}
const ringGeo = new THREE.BufferGeometry().setFromPoints(pts);
const ring = new THREE.Line(ringGeo, new THREE.LineBasicMaterial({ color: 0x57534e, opacity: 0.5, transparent: true }));
ring.position.x = msCenterX;
ringGroup.add(ring);
}
meshes.slatRoll.add(ringGroup);
}
// 나선형 표면 라인 (롤 표면에 감긴 느낌)
if (rollThick > 5) {
const spiralMat = new THREE.LineBasicMaterial({ color: S.productType === 'steel' ? 0x7A6040 : 0x6a3ddd, opacity: 0.4, transparent: true });
const spiralCount = 3;
for (let s = 0; s < spiralCount; s++) {
const spiralPts = [];
const turns = Math.max(2, Math.min(rollLen / 80, 20));
const startAngle = (s / spiralCount) * Math.PI * 2;
for (let i = 0; i <= turns * 32; i++) {
const t = i / (turns * 32);
const angle = startAngle + t * turns * Math.PI * 2;
const x = -rollLen / 2 + t * rollLen;
const surfR = rollOuterR + 0.5;
spiralPts.push(new THREE.Vector3(x, Math.sin(angle) * surfR, Math.cos(angle) * surfR));
}
const spiralGeo = new THREE.BufferGeometry().setFromPoints(spiralPts);
meshes.slatRoll.add(new THREE.Line(spiralGeo, spiralMat));
}
}
meshes.slatRoll.position.set(0, shaftY, 0);
scene.add(meshes.slatRoll);
}
// === BOTTOM BAR ===
@@ -1388,6 +1437,11 @@ function fs3dBuild() {
scene.add(meshes.wall);
// 재빌드 후 토글 상태 동기화 (새로 생성된 mesh에 visibility 적용)
Object.keys(S.td.show).forEach(key => {
if (meshes[key]) meshes[key].visible = S.td.show[key];
});
// Camera: 최초 빌드 시에만 위치 설정, 이후 재빌드 시 현재 시점 유지
if (!fs3dCameraInit) {
controls.target.set(0, H / 2, 0);