diff --git a/index.html b/index.html index ac69238..2b0cf1b 100644 --- a/index.html +++ b/index.html @@ -74,6 +74,7 @@

교각 3D 뷰

+
diff --git a/src/main.ts b/src/main.ts index 056ba3e..84c5e8d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -61,6 +61,7 @@ import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type 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 { buildAllPiers, type AllPiers } from './view/all-piers'; /** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ interface Pier extends PierValues { @@ -119,6 +120,8 @@ const state = { project: { name: '', bridge: '', client: '', author: '', crs: CRS, memo: '' } as ProjectInfo, /** Which stage screen is shown: ① 형상 생성 or ② 형상계획 (plan.md C1). */ stage: 'shape' as Stage, + /** 전체 보기 on ① 형상 생성: every pier's concrete in one frame (콘크리트만). */ + allView: false, /** ③ 공통 설정 (fck · fy). */ material: { ...DEFAULT_MATERIAL } as Material, /** ② 부재별 fck. A member without an entry uses the 공통 fck (③). Initial values from 교각도 (025·026, 승인 2026-10-27): 기둥·코핑 40, 기초 27. */ @@ -147,6 +150,8 @@ const state = { let pendingBackup: Backup | null = null; /** 3D rebar of the current pier (③); its bars are named as in the 철근표. */ let rebar3d: RebarScene | null = null; +/** 전체 보기: every pier's concrete in one frame (null in single-pier view). */ +let allPiers: AllPiers | null = null; let backupTimer: number | undefined; const $ = (id: string): T => document.getElementById(id) as T; @@ -291,7 +296,7 @@ function buildPartToggles(): void { input.addEventListener('change', () => { for (const p of group.parts) { state.visible[p] = input.checked; - if (state.scene) state.scene.parts[p].visible = input.checked; + setPartVisible(p, input.checked); } }); label.append(input, document.createTextNode(group.label)); @@ -311,6 +316,21 @@ function buildViewButtons(): void { } } +/** Scenes whose parts follow the 부재 표시 checkboxes: every pier in 전체 보기, else the active pier. */ +function partScenes(): PierScene[] { + if (allPiers) return [...allPiers.scenes.values()]; + return state.scene ? [state.scene] : []; +} + +function setPartVisible(part: PartName, on: boolean): void { + if (part === 'ground' && allPiers) { + // Per-pier ground planes stay hidden in 전체 보기; only the shared terrain follows the checkbox. + if (allPiers.terrain) allPiers.terrain.visible = on; + return; + } + for (const s of partScenes()) s.parts[part].visible = on; +} + function setView(kind: ViewKind): void { state.view = kind; document.querySelectorAll('#views button').forEach((b, i) => { @@ -319,20 +339,46 @@ function setView(kind: ViewKind): void { // The band shows the current view only; choosing one closes the menu (승인 2026-10-23). $('views-toggle').textContent = `${VIEWS.find((v) => v.kind === kind)?.label ?? ''} ▾`; setMenuOpen('views', false); - if (!state.scene) return; + const scene = allPiers ? allPiers.view : state.scene; + if (!scene) return; const pier = activePier(); if (!pier?.model) return; - const { position, target } = viewPosition(state.scene, kind, pier.model); + const { position, target } = viewPosition(scene, kind, pier.model); camera.position.copy(position); controls.target.copy(target); controls.update(); } +/** 전체 보기 applies on ① 형상 생성 only; ③ and the others keep the single-pier scene. */ +function allViewOn(): boolean { + return state.allView && state.stage === 'shape'; +} + +/** Builds 전체 보기 from every pier that has a model. Returns false when none does. */ +function buildAllScene(): boolean { + const entries = state.piers.flatMap((p) => (p.model ? [{ name: p.input.name, model: p.model }] : [])); + if (entries.length === 0) return false; + allPiers = buildAllPiers(entries, groundModel?.terrainGrid); + world.add(allPiers.piers); + if (allPiers.terrain) world.add(allPiers.terrain); + for (const key of Object.keys(state.visible) as PartName[]) setPartVisible(key, state.visible[key]); + return true; +} + function setupScene(): void { if (state.scene) world.remove(state.scene.root); state.scene = null; + if (allPiers) { + world.remove(allPiers.piers); + if (allPiers.terrain) world.remove(allPiers.terrain); + allPiers = null; + } if (rebar3d) world.remove(rebar3d.group); rebar3d = null; + if (allViewOn() && buildAllScene()) { + setView(state.view); + return; + } const pier = activePier(); if (pier?.model) { rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember); @@ -526,6 +572,9 @@ let lastScrolledPier: string | null = null; function renderAll(): void { updatePartsCount(); + const allToggle = $('all-toggle'); + allToggle.setAttribute('aria-pressed', String(state.allView)); + allToggle.disabled = state.stage !== 'shape' || !state.piers.some((p) => p.model); if (state.active !== lastScrolledPier) { lastScrolledPier = state.active; document.querySelector('.pier-items [aria-pressed="true"]')?.scrollIntoView({ block: 'nearest' }); @@ -919,6 +968,32 @@ function memberOfBar(name: string): RebarRow['member'] { return 'coping'; } +/** Click on a pier in 전체 보기 makes it the active pier and returns to the single-pier view. */ +function wireAllPiersPicking(): void { + const dom = renderer.domElement; + let down: { x: number; y: number } | null = null; + dom.addEventListener('pointerdown', (e) => { + down = { x: e.clientX, y: e.clientY }; + }); + dom.addEventListener('pointerup', (e) => { + if (!down || !allPiers) return; + if (Math.hypot(e.clientX - down.x, e.clientY - down.y) > 5) return; // a drag rotates the view + const rect = dom.getBoundingClientRect(); + const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1); + const ray = new THREE.Raycaster(); + ray.setFromCamera(ndc, camera); + const hit = ray.intersectObjects(allPiers.piers.children, true).find((h) => h.object.visible); + let node: THREE.Object3D | null = hit?.object ?? null; + while (node && !node.userData.pier) node = node.parent; + if (!node) return; + state.active = node.userData.pier as string; + state.allView = false; + state.rebarSelection = new Set(); + setupScene(); + renderAll(); + }); +} + /** Click on a 3D bar selects its 철근표 row (on ③ only). */ function wireRebarPicking(): void { const dom = renderer.domElement; @@ -944,6 +1019,8 @@ function wireRebarPicking(): void { function setStage(stage: Stage): void { state.stage = stage; + // Switching 전체 보기 on or off in ① needs the scene rebuilt for the new layout. + if ((allPiers !== null) !== allViewOn()) setupScene(); if (state.scene) setRebarView(state.scene, stage === 'rebar'); $('view-shape').hidden = stage !== 'shape'; $('view-plan').hidden = stage !== 'plan'; @@ -3067,6 +3144,7 @@ $('discard-backup').addEventListener('click', () => { // --- start ------------------------------------------------------------------------------ captureViewerHome(); wireRebarPicking(); +wireAllPiersPicking(); buildPartToggles(); buildViewButtons(); renderDefaults(); @@ -3120,6 +3198,12 @@ function restorePanel(id: string): void { restorePanel('parts-info'); restorePanel('color-info'); +$('all-toggle').addEventListener('click', () => { + state.allView = !state.allView; + setupScene(); + renderAll(); +}); + $('views-toggle').addEventListener('click', () => setMenuOpen('views', $('views').hidden === true)); $('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $('origin').hidden === true)); document.addEventListener('click', (ev) => { diff --git a/src/view/all-piers.ts b/src/view/all-piers.ts new file mode 100644 index 0000000..4d4004d --- /dev/null +++ b/src/view/all-piers.ts @@ -0,0 +1,52 @@ +import * as THREE from 'three'; +import type { PierModel, Vec2 } from '../core/geometry'; +import type { TerrainGrid } from '../core/ground-model'; +import { buildPierScene, type PierScene } from './scene'; +import { terrainGeometry } from './terrain'; + +/** + * Scene offset of a pier's group inside the shared frame of the reference pier. Each pier's scene is centred on its own + * pivot, so moving it by the pivot difference puts it at its national-grid place (x = E, z = −N, as in stretchMatrix). + */ +export function pierOffset(pivot: Vec2, reference: Vec2): { x: number; z: number } { + return { x: pivot.e - reference.e, z: reference.n - pivot.n }; +} + +export interface AllPiers { + /** Holds every pier's group, placed by pierOffset. Used for the camera fit and picking. */ + piers: THREE.Group; + /** Shared terrain grid in the reference frame, or null without a terrain model. */ + terrain: THREE.Mesh | null; + /** Scene of each pier, keyed by the pier name. Parts are shown per pier. */ + scenes: Map; + /** Scene with the same shape the single-pier view expects (root = pier group, size = whole span). */ + view: PierScene; +} + +/** + * Builds the concrete members of several piers in one frame (전체 보기, 콘크리트만: no rebar). The first entry is the + * reference. Each pier's own ground plane is hidden; the shared terrain grid is drawn once. + */ +export function buildAllPiers(entries: { name: string; model: PierModel }[], terrain?: TerrainGrid): AllPiers { + const piers = new THREE.Group(); + const scenes = new Map(); + const reference = entries[0].model.pivot; + for (const { name, model } of entries) { + const scene = buildPierScene(model); + scene.parts.ground.visible = false; + const { x, z } = pierOffset(model.pivot, reference); + scene.root.position.set(x, 0, z); + scene.root.userData.pier = name; + piers.add(scene.root); + scenes.set(name, scene); + } + let terrainMesh: THREE.Mesh | null = null; + if (terrain) { + const material = new THREE.MeshStandardMaterial({ color: 0xb8c7b0, roughness: 0.9, metalness: 0, transparent: true, opacity: 0.35, side: THREE.DoubleSide }); + terrainMesh = new THREE.Mesh(terrainGeometry(terrain, reference), material); + } + const box = new THREE.Box3().setFromObject(piers); + const size = box.getSize(new THREE.Vector3()).length(); + const view: PierScene = { root: piers, parts: scenes.get(entries[0].name)!.parts, size }; + return { piers, terrain: terrainMesh, scenes, view }; +} diff --git a/tests/all-piers.test.ts b/tests/all-piers.test.ts new file mode 100644 index 0000000..c09da9a --- /dev/null +++ b/tests/all-piers.test.ts @@ -0,0 +1,41 @@ +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { buildPier, type PierModel } from '../src/core/geometry'; +import { buildAllPiers, pierOffset } from '../src/view/all-piers'; +import { loadP1 } from './helpers'; + +const model = buildPier(loadP1()); + +describe('pierOffset', () => { + it('is zero for the reference pier', () => { + expect(pierOffset(model.pivot, model.pivot)).toEqual({ x: 0, z: 0 }); + }); + + it('moves east by the E difference and north by the N difference (z = −N)', () => { + const ref = { e: 259865, n: 179138 }; + const other = { e: 260117, n: 179177 }; + expect(pierOffset(other, ref)).toEqual({ x: 252, z: -39 }); + }); +}); + +describe('buildAllPiers', () => { + it('places a pier at the same world box whatever pivot it is built around', () => { + // The same pier with its pivot shifted: the geometry shifts by the opposite amount, so the world box must not change. + const shifted: PierModel = { ...model, pivot: { e: model.pivot.e + 50, n: model.pivot.n - 20 } }; + const all = buildAllPiers([{ name: 'P1', model }, { name: 'P2', model: shifted }]); + all.piers.updateMatrixWorld(true); + const a = new THREE.Box3().setFromObject(all.scenes.get('P1')!.root); + const b = new THREE.Box3().setFromObject(all.scenes.get('P2')!.root); + // Float32 geometry: allow 0.1 mm. + expect(b.min.distanceTo(a.min)).toBeLessThan(1e-4); + expect(b.max.distanceTo(a.max)).toBeLessThan(1e-4); + }); + + it('hides each pier ground plane and keys scenes by name', () => { + const all = buildAllPiers([{ name: 'P1', model }]); + expect([...all.scenes.keys()]).toEqual(['P1']); + expect(all.scenes.get('P1')!.parts.ground.visible).toBe(false); + expect(all.terrain).toBeNull(); + expect(all.view.size).toBeGreaterThan(0); + }); +});