diff --git a/src/core/project.ts b/src/core/project.ts index 450413e..610e702 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -68,7 +68,7 @@ export function parseProject(text: string): ProjectFile { if (!Array.isArray(obj.piers)) throw new Error('piers 목록이 없습니다.'); if (obj.fckByMember !== undefined) { for (const [member, value] of Object.entries(obj.fckByMember)) { - if (!['column', 'coping', 'footing'].includes(member) || typeof value !== 'number' || !(value > 0)) { + if (!['column', 'coping', 'footing', 'abutFooting'].includes(member) || typeof value !== 'number' || !(value > 0)) { throw new Error(`부재별 fck 값이 맞지 않습니다 (${member}: ${String(value)}).`); } } diff --git a/src/core/rebar/common.ts b/src/core/rebar/common.ts index c0e25df..48e4576 100644 --- a/src/core/rebar/common.ts +++ b/src/core/rebar/common.ts @@ -84,15 +84,15 @@ export function materialFrom(raw: unknown): Material { } /** Members that can take their own fck on ② (부재 카드). Other members always use the 공통 fck. */ -export type FckMember = 'column' | 'coping' | 'footing'; +export type FckMember = 'column' | 'coping' | 'footing' | 'abutFooting'; /** The members of {@link FckMember}, for the ② cards. */ -export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing']; +export const FCK_MEMBERS: readonly FckMember[] = ['column', 'coping', 'footing', 'abutFooting']; /** Per-member fck (MPa). A member without an entry uses the ③ 공통 설정 fck. */ export type FckByMember = Partial>; -/** 교각도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. */ -export const IMPORTED_FCK: Record = { column: 40, coping: 40, footing: 27 }; +/** 교각도 · 교대도 기준 부재별 기본 fck (MPa). 되돌리기는 이 값으로 돌아가고, 이 값과 다르면 주황색으로 표시합니다. 교대 저판 27 (spec_abut §6, 2026-11-03). */ +export const IMPORTED_FCK: Record = { column: 40, coping: 40, footing: 27, abutFooting: 27 }; /** True when the member's fck is set and differs from its imported default. An unset member is not changed. */ export function fckChanged(byMember: FckByMember, member: FckMember): boolean { diff --git a/src/main.ts b/src/main.ts index c300711..fe8d2c2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -109,7 +109,7 @@ import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, t import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup'; import type { Issue, PierInput, PierType, StrataRow } from './core/types'; -import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; +import { buildPierScene, setRebarView, setRebarViewOn, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; import { buildAllPiers, type AllPiers } from './view/all-piers'; import { buildTerrainMapGroup, disposeTerrainMapGroup, setTerrainMapMode } from './view/terrain-map'; @@ -631,6 +631,7 @@ function setupScene(): void { const member = group.getObjectByName(name); if (member) member.visible = on; } + setRebarViewOn(group, state.stage === 'rebar'); abutGroups.push(group); abutGroupByName.set(rec.name, group); world.add(group); @@ -1486,6 +1487,18 @@ function fillAbutFootingExtras( auto('길이 (자동)', `${L.toFixed(3)} m`, '교좌면 길이 (skew)'); auto('말뚝 길이', `${Math.round(rec.pileLengthMm)} mm`, '① 기본 정보에서 수정'); auto('선단 EL (자동)', built.members.pileBottomEl === undefined ? '—' : `${built.members.pileBottomEl.toFixed(3)} m`, '= 기초 하단 − 말뚝 길이'); + // 교대 저판 fck: 교각 기초 fck와 따로 둔다 (사용자 결정 2026-11-04). 비우면 기본값으로 돌아간다. + const fckId = idOf('fck'); + const fckLab = el('label', 'fck (MPa)'); + fckLab.htmlFor = fckId; + const fckIn = el('input'); + fckIn.id = fckId; + fckIn.type = 'number'; + fckIn.step = 'any'; + fckIn.value = String(state.fckByMember.abutFooting ?? state.material.fck); + if (fckChanged(state.fckByMember, 'abutFooting')) fckIn.classList.add('changed-input'); + fckIn.addEventListener('change', () => setMemberFck('abutFooting', fckIn.value)); + box.append(fckLab, fckIn, el('span', `기본 ${IMPORTED_FCK.abutFooting} · 교각 기초와 별도 · 배근에만 반영`, 'muted small')); // 열 위치 (배면부터): "650 · 2000 · 1300 · 650". The last one (전면 연단) follows from the 폭. const id = idOf('pile-rows'); const lab = el('label', '열 위치 · 배면부터 (mm)'); @@ -2549,6 +2562,7 @@ function setStage(stage: Stage): void { // Entering or leaving ② changes which objects are drawn (visibility.ts), so the scene is rebuilt then. if ((allPiers !== null) !== multiSceneOn() || stage === 'plan' || previous === 'plan') setupScene(); if (state.scene) setRebarView(state.scene, stage === 'rebar'); + for (const g of abutGroups) setRebarViewOn(g, stage === 'rebar'); $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; $('view-rebar').hidden = stage !== 'rebar'; @@ -3083,7 +3097,7 @@ function abutFootingBuilt(rec: AbutRecord): { geom: AbutFootingGeom; result: Abu const model = buildAbut(toAbutInput(rec)); const members = buildMembers(model, { bottomEl: rec.foundationBottomEl, pileLengthMm: rec.pileLengthMm }, rec.shape); const geom = abutFootingGeom(model.length, members.shape, skewAngle(rec.skewDeg)); - const result = abutFootingRebar(geom, rec.rebar?.footing, materialFor(state.material, state.fckByMember, 'footing'), rec.name); + const result = abutFootingRebar(geom, rec.rebar?.footing, materialFor(state.material, state.fckByMember, 'abutFooting'), rec.name); return { geom, result, skewK: abutSkewK(model) }; } catch { return null; @@ -3273,7 +3287,7 @@ function fillAbutFootingRebarCard(box: HTMLDivElement, rec: AbutRecord, built: { grid2.append(cell); } // 자동 값. - const fck = materialFor(state.material, state.fckByMember, 'footing').fck; + const fck = materialFor(state.material, state.fckByMember, 'abutFooting').fck; const fy = (size: string) => fyOf(state.material, size); const auto = (label: string, value: string) => { const cell = el('div', undefined, 'abut-rebar-auto'); diff --git a/src/view/scene.ts b/src/view/scene.ts index f468267..c94ba0f 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -208,17 +208,21 @@ const REBAR_VIEW_ALPHA = 0.5; /** Turns the ③ rebar view on or off. Off restores each part's own opacity. Depth writes stop while on, so bars behind a member stay visible. */ export function setRebarView(scene: PierScene, on: boolean): void { - for (const group of Object.values(scene.parts)) { - group.traverse((o) => { - if (!(o instanceof THREE.Mesh)) return; - const mat = o.material as THREE.MeshStandardMaterial; - const base = mat.userData.baseOpacity as number; - mat.opacity = on ? REBAR_VIEW_ALPHA : base; - mat.transparent = on || base < 1; - mat.depthWrite = !on; - mat.needsUpdate = true; - }); - } + for (const group of Object.values(scene.parts)) setRebarViewOn(group, on); +} + +/** {@link setRebarView} for any group (교대 3D). A material without a recorded base opacity takes its current one as the base. */ +export function setRebarViewOn(root: THREE.Object3D, on: boolean): void { + root.traverse((o) => { + if (!(o instanceof THREE.Mesh)) return; + const mat = o.material as THREE.MeshStandardMaterial; + mat.userData.baseOpacity ??= mat.opacity; + const base = mat.userData.baseOpacity as number; + mat.opacity = on ? REBAR_VIEW_ALPHA : base; + mat.transparent = on || base < 1; + mat.depthWrite = !on && base >= 1; + mat.needsUpdate = true; + }); } export type ViewKind = 'iso' | 'front' | 'side' | 'top'; diff --git a/tests/abut-scene.test.ts b/tests/abut-scene.test.ts index 8fff2b3..0924a4f 100644 --- a/tests/abut-scene.test.ts +++ b/tests/abut-scene.test.ts @@ -8,6 +8,7 @@ import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; import { buildMembers } from '../src/core/abut/members'; import { buildWings } from '../src/core/abut/wings'; import { abutPlacement, buildAbutGroup, cameraForBox, covers } from '../src/view/abut-scene'; +import { setRebarViewOn } from '../src/view/scene'; import type { TerrainGrid } from '../src/core/ground-model'; const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); @@ -132,3 +133,37 @@ describe('교대 선택 시 카메라', () => { expect(cam.position.distanceTo(cam.target)).toBeGreaterThan(12); }); }); + +describe('③ 배근 보기 반투명 (교각과 같음, 2026-11-04)', () => { + const g = buildAbutGroup(model, members, wings, undefined, GROUND_EL); + const mats = () => { + const out: THREE.MeshStandardMaterial[] = []; + g.traverse((o) => { + if (o instanceof THREE.Mesh) out.push(o.material as THREE.MeshStandardMaterial); + }); + return out; + }; + const ground = () => (g.getObjectByName('ground') as THREE.Group).children[0] as THREE.Mesh; + + it('turns every member and the ground to half opacity with depth writes off', () => { + setRebarViewOn(g, true); + for (const m of mats()) { + expect(m.opacity).toBe(0.5); + expect(m.transparent).toBe(true); + expect(m.depthWrite).toBe(false); + } + }); + + it('restores each part: members opaque, the ground at 0.35 without depth writes', () => { + setRebarViewOn(g, false); + const groundMat = ground().material as THREE.MeshStandardMaterial; + for (const m of mats()) { + if (m === groundMat) continue; + expect(m.opacity).toBe(1); + expect(m.transparent).toBe(false); + expect(m.depthWrite).toBe(true); + } + expect(groundMat.opacity).toBe(0.35); + expect(groundMat.depthWrite).toBe(false); + }); +}); diff --git a/tests/member-fck.test.ts b/tests/member-fck.test.ts index c71cbf0..35108bc 100644 --- a/tests/member-fck.test.ts +++ b/tests/member-fck.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { fckChanged, IMPORTED_FCK } from '../src/core/rebar/common'; +import { DEFAULT_MATERIAL, fckChanged, IMPORTED_FCK, materialFor } from '../src/core/rebar/common'; -describe('IMPORTED_FCK (교각도 기준 부재별 기본 fck)', () => { - it('is 40 for 기둥·코핑 and 27 for 확대기초', () => { - expect(IMPORTED_FCK).toEqual({ column: 40, coping: 40, footing: 27 }); +describe('IMPORTED_FCK (교각도 · 교대도 기준 부재별 기본 fck)', () => { + it('is 40 for 기둥·코핑, 27 for 확대기초 and 27 for 교대 저판', () => { + expect(IMPORTED_FCK).toEqual({ column: 40, coping: 40, footing: 27, abutFooting: 27 }); }); }); describe('fckChanged (기본과 다르면 주황색)', () => { it('is false at the imported default', () => { - expect(fckChanged({ column: 40, coping: 40, footing: 27 }, 'coping')).toBe(false); + expect(fckChanged({ column: 40, coping: 40, footing: 27, abutFooting: 27 }, 'coping')).toBe(false); }); it('is true when the member differs from its imported default', () => { @@ -21,3 +21,15 @@ describe('fckChanged (기본과 다르면 주황색)', () => { expect(fckChanged({}, 'column')).toBe(false); }); }); + +describe('교대 저판 fck (교각 기초와 별도, 2026-11-04)', () => { + it('follows abutFooting, not the pier footing', () => { + const byMember = { footing: 30, abutFooting: 35 }; + expect(materialFor(DEFAULT_MATERIAL, byMember, 'abutFooting').fck).toBe(35); + expect(materialFor(DEFAULT_MATERIAL, byMember, 'footing').fck).toBe(30); + }); + + it('falls back to the 공통 fck when unset (old project files)', () => { + expect(materialFor({ ...DEFAULT_MATERIAL, fck: 24 }, { footing: 30 }, 'abutFooting').fck).toBe(24); + }); +});