feat(view): 전체 보기로 6기 콘크리트를 한 화면에 표시
- 교각별 축 위치(pivot E·N 차이)로 장면을 배치하는 pierOffset과 buildAllPiers 추가 - 지형 격자는 기준 교각(첫 교각) 좌표로 한 번만 그림, 교각별 지반면은 숨김 - 철근은 표시하지 않음 (콘크리트만). ① 형상 생성에서만 동작 - 전체 보기에서 교각을 클릭하면 그 교각을 활성으로 하고 단일 교각 보기로 전환 - 테스트: 위치 오프셋 부호, 피벗을 옮겨도 같은 월드 상자 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab04c19afb
commit
a2ad0f2baf
4 files changed
+181
-3
No files matched your search
@@ -74,6 +74,7 @@
|
||||
<section class="panel viewer" id="viewer-block" aria-labelledby="viewer-title">
|
||||
<div class="viewer-head">
|
||||
<h2 id="viewer-title">교각 3D 뷰</h2>
|
||||
<button id="all-toggle" class="btn" type="button" aria-pressed="false" title="6기를 콘크리트만 한 화면에 (① 형상 생성)">전체 보기</button>
|
||||
</div>
|
||||
<div id="canvas" class="canvas" aria-label="교각 3D 뷰"></div>
|
||||
<div class="viewer-stack viewer-stack-right">
|
||||
|
||||
+87
-3
@@ -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 $ = <T extends HTMLElement>(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<HTMLButtonElement>('#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 = $<HTMLButtonElement>('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<HTMLButtonElement>('.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');
|
||||
$<HTMLElement>('view-shape').hidden = stage !== 'shape';
|
||||
$<HTMLElement>('view-plan').hidden = stage !== 'plan';
|
||||
@@ -3067,6 +3144,7 @@ $<HTMLButtonElement>('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');
|
||||
|
||||
$<HTMLButtonElement>('all-toggle').addEventListener('click', () => {
|
||||
state.allView = !state.allView;
|
||||
setupScene();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>('views-toggle').addEventListener('click', () => setMenuOpen('views', $<HTMLDivElement>('views').hidden === true));
|
||||
$<HTMLButtonElement>('origin-toggle').addEventListener('click', () => setMenuOpen('origin', $<HTMLSpanElement>('origin').hidden === true));
|
||||
document.addEventListener('click', (ev) => {
|
||||
|
||||
@@ -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<string, PierScene>;
|
||||
/** 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<string, PierScene>();
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user