diff --git a/resources/views/rd/fire-shutter-drawing/index.blade.php b/resources/views/rd/fire-shutter-drawing/index.blade.php index 2d320b33..baa78a8c 100644 --- a/resources/views/rd/fire-shutter-drawing/index.blade.php +++ b/resources/views/rd/fire-shutter-drawing/index.blade.php @@ -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);