feat(B06,B05): 기슭막이 연동·경사를 재정의하고 링크 카드까지 강조한다

사용자 확정(2026-08-30) 세 가지를 반영한다.

① 링크 강조 — 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다. 선택
(조정창)과 강조를 분리해 `highlightFor`/`applyRevetHighlight`로 카드 재생성 없이
클래스만 갈아 끼운다(줌·팬 보존). 다단은 단 번호까지 같은 키로 짝짓는다.

② 연동 = 분리가 아니다 — 길이·전/후로 이미 하나의 기슭막이다. 연동을 푼 측점은
그 자리에 서므로 3D에서 벽이 노선과 평행이 아니라 자기 경로선을 따라간다. 링마다
폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 해제 시 단 수가
0이 되던 것(벽 4→2)도 고쳤다 — 소유 측점 4축에서 출발해 손댄 벽만 갈아 끼운다.

③ 경사 = 수평(Z 일원화) — 3D 링만 눕히던 것을 횡단도까지 넓혔다. 링크 카드 벽
상단 표고를 소유 측점과 맞추고(d = 소유 d + 1.2×dz) 차이는 노견 확장이 흡수한다.
그래서 수평 중에는 연동 해제 불가 — 버튼을 잠그고, 해제해 둔 값은 지운다.

곁다리: 3D 재빌드 해시에 extra_spans·revet_link_detached 추가(안 넣으면 저장
코리도가 안 만료된다), 캐시 저장 payload에 extra_spans 누락 보완, 3D 머리 주석을
"정본만 읽는다" → "캐시를 읽는다"로 정정.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 12:01:22 +09:00
co-authored by Claude Opus 5
parent 08c0dc3228
commit 0d4e4c758d
8 changed files with 502 additions and 115 deletions
@@ -11,15 +11,16 @@
* · 집수정: 부재 폴리곤 대신 **전체 직육면체**(부재 외곽 박스 — 2026-08-23
* 사용자). 연장 = basin_length/before/after_m(기본 2m·1/1 — 2026-08-24).
* · 배관: 관 하단 시·끝점(횡단면 안 대각선)을 축으로 하는 원통, 지름 = 관경.
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 확정 시 정본(design.revet_adjust·
* extra_wall_counts)에 실린다 — 3D는 **정본만** 읽는다(2026-08-24 사용자 확정: 3D는
* 종단·횡단 확정 뒤의 최종 산출물이며 3D 쪽 편집은 없다). 확정 전 세션 값은 반영하지
* 않는다.
* 조정창 조작값(기슭막이 4축 x·d·h·m, 다단 단 수)은 **캐시**(`section.design`)에 그 자리에서
* 실린다 — 3D는 그 **캐시를 읽는다**(2026-08-30 사용자 확정. 영구저장소 정본이 아니라
* 캐시가 지금 화면의 값이다). B06 조정창이 캐시를 고치면 코리도 해시가 바뀌어 다시 빌드된다.
*
* 연동(2026-08-30 사용자 확정): 기슭막이는 길이·전/후로 **이미 하나**다. 연동을 푼 측점은
* 그 측점 자리에 서므로 벽이 노선과 평행이 아니라 **자기 경로선**을 따라간다 — 링마다
* 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
@@ -39,6 +40,13 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box";
import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing";
import {
buildWallPath,
detachedControlsOf,
polygonAt,
storedAdjusts,
type WallPath,
} from "./B05_Profile_UI_Corridor_Structures_Path";
import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
@@ -163,27 +171,6 @@ function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [nu
*/
const layoutCache = new WeakMap<CrossSection, { hash: string; layout: CulvertLayout | null }>();
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
function storedAdjusts(
section: CrossSection,
):
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined {
const stored = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
if (!stored && !counts) return undefined;
const at = (role: string): WallAdjust =>
stored?.[role]
? { ...ZERO_ADJUST, ...(stored[role] as Partial<WallAdjust>) }
: { ...ZERO_ADJUST };
return {
inlet: at("inlet"),
outlet: at("outlet"),
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)),
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)),
};
}
/** BOX암거 정본 조작값 — 확정 전 세션 값은 안 읽는다(2026-08-24 규칙). */
export function boxAdjustOf(section: CrossSection): BoxAdjust {
const stored = section.design?.box_adjust;
@@ -301,41 +288,38 @@ export function buildCorridorStructures(
// 본다. 이 스위치는 3D 전용이다 — 횡단도는 언제나 그 측점 계획고로 그린다.
const followGrade = section.design?.revet_follow_grade !== false;
const baseZ = followGrade ? (frameAt(chainage)?.designZ ?? null) : null;
const ringsOf = (beforeM: number, afterM: number): StructureFrame[] => {
/** 스윕 링이 설 누가거리 열 — 프레임과 폴리곤 보간이 같은 자리를 쓴다. */
const ringChainagesOf = (beforeM: number, afterM: number): number[] => {
const from = chainage - beforeM;
const to = chainage + afterM;
const count = Math.max(2, Math.ceil((to - from) / SWEEP_STEP_M) + 1);
const rings: StructureFrame[] = [];
for (let i = 0; i < count; i += 1) {
const at = from + ((to - from) * i) / (count - 1);
const frame = frameAt(at);
if (!frame) {
rings.push(stationFrame);
continue;
}
rings.push({
cx: frame.cx,
cy: frame.cy,
leftX: frame.leftX,
leftY: frame.leftY,
dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0,
});
}
return rings;
return Array.from({ length: count }, (_unused, i) => from + ((to - from) * i) / (count - 1));
};
const pushSwept = (
kind: "revet" | "basin",
points: Array<{ offset: number; elevation: number }>,
beforeM: number,
afterM: number,
/** 벽 경로선 — 연동을 푼 측점이 있으면 링마다 단면을 갈아 끼운다(2026-08-30). */
path?: WallPath | null,
): void => {
if (points.length < 3) return;
const chainages = ringChainagesOf(beforeM, afterM);
const rings = ringsAt(chainages);
if (!path) {
solids.push({
chainage_m: chainage,
kind,
polygon: points.map((p) => [p.offset, p.elevation]),
rings,
});
return;
}
solids.push({
chainage_m: chainage,
kind,
polygon: points.map((p) => [p.offset, p.elevation]),
rings: ringsOf(beforeM, afterM),
polygons: chainages.map((at) => polygonAt(path, at)),
rings,
});
};
@@ -378,6 +362,8 @@ export function buildCorridorStructures(
},
/** 보어(구멍) 중심 누가거리 목록 — 세월교는 련수만큼(2026-08-25 사용자 ⑥). */
boreChainages: number[] = [chainage],
/** 벽 경로선(2026-08-30) — 링마다 그 자리 단면을 잘라 구멍을 낸다. */
path?: WallPath | null,
): void => {
if (points.length < 3) return;
const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]);
@@ -385,7 +371,7 @@ export function buildCorridorStructures(
const right = Math.min(...polygon.map(([offset]) => offset));
// 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다.
if (left < bore.minOffset || right > bore.maxOffset) {
pushSwept(kind, points, beforeM, afterM);
pushSwept(kind, points, beforeM, afterM, path);
return;
}
// 보어마다 촘촘한 링을 깐 뒤 합친다 — 링이 성기면 원형이 사라진다.
@@ -423,7 +409,7 @@ export function buildCorridorStructures(
clamped.bottom = center;
clamped.top = center;
}
const [top, bottom] = splitByBand(polygon, clamped);
const [top, bottom] = splitByBand(path ? polygonAt(path, at) : polygon, clamped);
upper.push(top);
lower.push(bottom);
}
@@ -532,21 +518,37 @@ export function buildCorridorStructures(
const basinSpan = basinSpanOf(section);
const hiddenPipe = section.culvert?.hidden_pipe === true;
const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet);
// 연동을 푼 측점이 있으면 벽은 노선과 평행이 아니라 **자기 경로선**을 따라간다
// (2026-08-30 사용자). 벽 하나에 솔리드 하나인 것은 그대로다 — 링 단면만 바뀐다.
const detachedControls = detachedControlsOf(section, crossSections);
const pathOf = (
key: string,
points: Array<{ offset: number; elevation: number }>,
): WallPath | null =>
detachedControls.length
? buildWallPath(
{ at: chainage, polygon: points.map((p) => [p.offset, p.elevation]) },
detachedControls,
key,
)
: null;
for (const wall of layout.walls) {
const span = wall.role === "inlet" ? inletSpan : outletSpan;
const path = pathOf(wall.role, wall.points);
// 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다.
if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM);
else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore);
if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM, path);
else
pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore, [chainage], path);
}
// 다단은 **단별 구간값**을 따른다(2026-08-29 사용자 — 단마다 연장이 다르다).
// 값이 없는 단은 고정 기본 10m(5/5)로 선다(`tierSpanOf`).
layout.extraWalls.forEach((wall, i) => {
const span = tierSpanOf(section, `extra${i}`);
pushSwept("revet", wall.points, span.beforeM, span.afterM);
pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`extra${i}`, wall.points));
});
layout.basinExtras.forEach((wall, i) => {
const span = tierSpanOf(section, `bextra${i}`);
pushSwept("revet", wall.points, span.beforeM, span.afterM);
pushSwept("revet", wall.points, span.beforeM, span.afterM, pathOf(`bextra${i}`, wall.points));
});
if (layout.basin) {
@@ -701,5 +703,12 @@ export function structureHashParts(section: CrossSection): Array<string | number
: "",
counts ? `${counts.outlet},${counts.basin}` : "",
section.design?.revet_follow_grade === false ? "flatZ" : "",
// 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼
// 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30).
Object.entries(section.design?.extra_spans ?? {})
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`)
.join("|"),
section.design?.revet_link_detached === true ? "detached" : "",
];
}
@@ -0,0 +1,203 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Path.ts
* 기슭막이 **벽 경로선** — 연동을 푼 측점이 있을 때 벽이 노선과 평행하지 않고 자기
* 경로를 따라가도록, 측점별 단면 폴리곤을 링마다 보간한다(2026-08-30 사용자 확정:
* "연동은 분리가 아니다 — 하나의 기슭막이가 스플라인 형태의 경로를 따라간다").
*
* 순수 기하만 둔다(Three.js·레이아웃 계산 무의존). 측점 폴리곤을 만드는 쪽은
* `_Corridor_Structures.ts`이고, 여기서는 **꼭짓점별 Catmull-Rom**으로 잇기만 한다.
* 꼭짓점 수가 다른 측점은 로프트 토폴로지가 깨지므로 제어점에서 뺀다 — 그런 벽은
* 소유 측점 폴리곤 하나로 고정(종전 동작)된다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import {
culvertOwnerFor,
culvertReach,
restrictToSide,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
/** 단면 폴리곤 [offset, 표고][] — 코리도 솔리드가 쓰는 그 형식. */
export type SectionPolygon = Array<[number, number]>;
/** 경로선 제어점 하나 — 그 측점 누가거리와 거기서 잰 벽 단면. */
export interface WallPathControl {
at: number;
polygon: SectionPolygon;
}
/** 누가거리 오름차순 제어점 열. 2개 이상이라야 경로선이 생긴다. */
export type WallPath = WallPathControl[];
/** 조정창 키(`inlet`·`outlet`·`extra0`·`bextra0`…)로 그 벽 단면을 꺼낸다. */
export function wallPolygonOf(layout: CulvertLayout, key: string): SectionPolygon | null {
const wall = key.startsWith("bextra")
? layout.basinExtras[Number(key.slice(6))]
: key.startsWith("extra")
? layout.extraWalls[Number(key.slice(5))]
: layout.walls.find((candidate) => candidate.role === key);
if (!wall || wall.points.length < 3) return null;
return wall.points.map((point) => [point.offset, point.elevation]);
}
/**
* 소유 측점 단면 + 연동을 푼 옆 측점 단면으로 경로선을 만든다. 제어점이 소유 측점
* 하나뿐이거나 꼭짓점 수가 어긋나면 null — 호출부는 종전대로 단일 폴리곤을 스윕한다.
*/
export function buildWallPath(
base: WallPathControl,
others: ReadonlyArray<{ at: number; layout: CulvertLayout }>,
key: string,
): WallPath | null {
const path: WallPath = [base];
for (const other of others) {
const polygon = wallPolygonOf(other.layout, key);
// 꼭짓점 수가 다르면 로프트 링 토폴로지가 깨진다 — 그 측점은 제어점에서 뺀다.
if (!polygon || polygon.length !== base.polygon.length) continue;
path.push({ at: other.at, polygon });
}
if (path.length < 2) return null;
return path.sort((a, b) => a.at - b.at);
}
/** 제어값 열의 Catmull-Rom 보간(끝 밖은 끝값 유지). 제어점 2개면 사실상 선형. */
function splineValue(values: number[], ats: number[], at: number): number {
const last = values.length - 1;
if (last < 1) return values[0];
if (at <= ats[0]) return values[0];
if (at >= ats[last]) return values[last];
let i = 0;
while (i < last - 1 && ats[i + 1] < at) i += 1;
const span = ats[i + 1] - ats[i];
const t = span <= 1e-9 ? 0 : (at - ats[i]) / span;
const p0 = i > 0 ? values[i - 1] : values[i];
const p1 = values[i];
const p2 = values[i + 1];
const p3 = i + 2 <= last ? values[i + 2] : values[i + 1];
const t2 = t * t;
const t3 = t2 * t;
return (
0.5 *
(2 * p1 +
(-p0 + p2) * t +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 +
(-p0 + 3 * p1 - 3 * p2 + p3) * t3)
);
}
/** 그 누가거리의 단면 — 꼭짓점마다 offset·표고를 따로 보간한다. */
export function polygonAt(path: WallPath, at: number): SectionPolygon {
const ats = path.map((control) => control.at);
return path[0].polygon.map((_vertex, index) => [
splineValue(
path.map((control) => control.polygon[index][0]),
ats,
at,
),
splineValue(
path.map((control) => control.polygon[index][1]),
ats,
at,
),
]);
}
/** 정본(design.revet_adjust·extra_wall_counts)을 기하 입력 형태로 되접는다. */
export function storedAdjusts(
section: CrossSection,
):
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined {
const stored = section.design?.revet_adjust;
const counts = section.design?.extra_wall_counts;
if (!stored && !counts) return undefined;
const at = (role: string): WallAdjust =>
stored?.[role]
? { ...ZERO_ADJUST, ...(stored[role] as Partial<WallAdjust>) }
: { ...ZERO_ADJUST };
return {
inlet: at("inlet"),
outlet: at("outlet"),
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) => at(`extra${i}`)),
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) => at(`bextra${i}`)),
};
}
/**
* 연동을 푼 측점의 4축 — **소유 측점 값에서 출발**해 이 측점이 손댄 벽만 갈아 끼운다.
* 단 수는 소유 측점 것이다(2026-08-30 사용자: 연동 해제는 분리가 아니다). 횡단도
* `detachedAdjusts`와 같은 규칙 — 3D가 2D와 다른 벽을 세우면 안 된다.
*/
function detachedAdjusts(
owner: CrossSection,
section: CrossSection,
): ReturnType<typeof storedAdjusts> {
const base = storedAdjusts(owner);
const mine = section.design?.revet_adjust;
const counts = owner.design?.extra_wall_counts;
const pick = (role: string, fallback?: WallAdjust): WallAdjust =>
mine?.[role]
? { ...ZERO_ADJUST, ...(mine[role] as Partial<WallAdjust>) }
: (fallback ?? { ...ZERO_ADJUST });
return {
inlet: pick("inlet", base?.inlet),
outlet: pick("outlet", base?.outlet),
extras: Array.from({ length: counts?.outlet ?? 0 }, (_unused, i) =>
pick(`extra${i}`, base?.extras[i]),
),
basinExtras: Array.from({ length: counts?.basin ?? 0 }, (_unused, i) =>
pick(`bextra${i}`, base?.basinExtras[i]),
),
};
}
/**
* **연동을 푼** 옆 측점의 기하 — 소유 측점 **스펙**을 그 측점 지형·조작값 위에 세운다.
* 횡단도 링크 카드(`computeCardCulvert`)와 같은 규칙이다: 구조물 형식은 언제나 소유
* 측점 것, 4축은 이 측점 것. 벽 경로선의 제어점으로 쓴다(2026-08-30 사용자).
*/
export function detachedLayoutOf(owner: CrossSection, section: CrossSection): CulvertLayout | null {
if (!owner.culvert || !section.design) return null;
const hosted: CrossSection = { ...section, culvert: owner.culvert };
try {
const layout = computeCulvertLayout(
hosted,
section.samples,
detachedAdjusts(owner, section),
owner.culvert.hidden_pipe ? "revet" : (owner.design?.inlet_structure ?? "auto"),
section.design.basin_adjust,
);
if (layout && owner.culvert.hidden_pipe) return restrictToSide(layout, owner.culvert.side);
return layout;
} catch {
return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
}
}
/**
* 이 구조물의 연장 안에서 **연동을 푼** 측점들의 기하. 경사 해제(수평)면 연동을 풀 수
* 없으므로 빈 배열이다 — 그때는 소유 측점 단면 하나가 Z 그대로 눕는다(2026-08-30).
*/
export function detachedControlsOf(
owner: CrossSection,
sections: ReadonlyArray<CrossSection>,
): Array<{ at: number; layout: CulvertLayout }> {
if (owner.design?.revet_follow_grade === false) return [];
const reach = culvertReach(owner);
if (!reach) return [];
const controls: Array<{ at: number; layout: CulvertLayout }> = [];
for (const section of sections) {
if (section === owner || section.design?.revet_link_detached !== true) continue;
const deltaM = section.chainage_m - owner.chainage_m;
if (deltaM < -reach.beforeM - 1e-9 || deltaM > reach.afterM + 1e-9) continue;
// 겹치는 구조물이 있으면 가장 가까운 측점이 주인이다 — 남의 벽을 끌어오지 않는다.
if (culvertOwnerFor(section, sections) !== owner) continue;
const layout = detachedLayoutOf(owner, section);
if (layout) controls.push({ at: section.chainage_m, layout });
}
return controls;
}
+1
View File
@@ -105,6 +105,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
put("ford_adjust", design.ford_adjust);
put("box_adjust", design.box_adjust);
put("extra_wall_counts", design.extra_wall_counts);
put("extra_spans", design.extra_spans);
put("revet_link_detached", design.revet_link_detached);
put("revet_follow_grade", design.revet_follow_grade);
if (touched) patches.push(patch);
+103 -11
View File
@@ -10,6 +10,7 @@ import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Sectio
import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types";
import {
basinSpanOfSpec,
FILL_SLOPE_RATIO_MIN,
materialLabel,
materialLimit,
revetSpanOfSpec,
@@ -29,8 +30,16 @@ import {
*/
export interface RevetOffsetControl {
adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust;
/** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */
storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null;
selectedFor: (section: CrossSection) => RevetKey | null;
/** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다
* (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다).
* 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */
highlightFor: (section: CrossSection) => RevetKey | null;
select: (chainageM: number, key: RevetKey | null) => void;
/** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */
onSelectionChange?: () => void;
/** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */
syncApplied: (chainageM: number, role: RevetKey, applied: WallAdjust) => void;
/** 일부 축만 바꾼다 — 나머지는 유지. h: null = 기본값 복귀. */
@@ -154,7 +163,7 @@ export function tierSpanOf(section: CrossSection, key: string): StructureSpan {
}
/** 구조물이 종방향으로 덮는 범위(기준측점 전/후 m) — 기슭막이·집수정·다단 중 큰 값. */
function culvertReach(section: CrossSection): StructureSpan | null {
export function culvertReach(section: CrossSection): StructureSpan | null {
const culvert = section.culvert;
if (!culvert) return null;
let beforeM = 0;
@@ -205,6 +214,26 @@ export function culvertOwnerFor(
return best;
}
/**
* 그 벽(기준벽·다단)이 **이 측점 카드에도 서는가** — 연동 하이라이트 짝짓기용
* (2026-08-30 사용자). 판정은 그리기(`trimLinkedLayout`)와 같은 구간값을 쓴다:
* 기준벽은 자기 길이·전/후, 다단은 단별 구간값(`tierSpanOf`).
*/
export function wallStandsAt(owner: CrossSection, section: CrossSection, key: string): boolean {
const deltaM = section.chainage_m - owner.chainage_m;
if (Math.abs(deltaM) < 0.01) return true;
const spec = owner.culvert;
if (!spec) return false;
if (key.startsWith("extra") || key.startsWith("bextra")) {
return spanCovers(tierSpanOf(owner, key), deltaM);
}
const side = key === "inlet" ? spec.inlet : key === "outlet" ? spec.outlet : null;
if (!side) return false;
// 집수정은 소유 측점 하나에만 선다(그리기와 같은 규칙) — 링크 카드에는 없다.
if (side.structure === "집수정" && !spec.hidden_pipe) return false;
return spanCovers(revetSpanOfSpec(side), deltaM);
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
@@ -352,23 +381,41 @@ export function computeCardCulvert(
// 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다.
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
const ownerAdjusts = detached
? adjustsInput(section, revetOffset, extraWalls)
: (computeCulvertLayout(
link.source,
link.source.samples,
adjustsInput(link.source, revetOffset, extraWalls),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls));
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
const ownerApplied =
computeCulvertLayout(
link.source,
link.source.samples,
ownerInput,
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(link.source),
)?.revetShift ?? ownerInput;
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
// 벽만 자기 값으로 선다(2026-08-30 사용자).
const ownerAdjusts = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
// 경사 해제 = **수평(Z 일원화)**. 3D 링만 눕히는 게 아니라 횡단도에서도 기준벽
// 상단 표고를 소유 측점과 같게 맞춘다(2026-08-30 사용자 확정 — 종전 "3D 전용"
// 규정을 갈아엎는다). 차이는 노견 확장 구간이 흡수하므로 성토선은 이 측점
// 노견에서 그대로 출발한다(2026-08-24 "성토선이 노견에서 떨어진다" 지적 회피).
const followGrade =
revetLink?.followGradeFor(link.source) ?? link.source.design?.revet_follow_grade !== false;
const layout = computeCulvertLayout(
hosted,
sourceSamples,
ownerAdjusts,
followGrade ? ownerAdjusts : levelToOwner(ownerAdjusts, link.dz),
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
inletStructure?.adjustFor(detached ? section : link.source),
);
if (!layout) return null;
// 노견 위로는 못 올라간다(기하가 d를 0에서 자른다) — 조작 중인 벽만 이유를 알린다.
if (!followGrade && (activeRevet === "inlet" || activeRevet === "outlet")) {
const requested = levelToOwner(ownerAdjusts, link.dz);
const applied = activeRevet === "inlet" ? layout.revetShift.inlet : layout.revetShift.outlet;
const asked = activeRevet === "inlet" ? requested?.inlet : requested?.outlet;
if (asked?.d != null && applied.d != null && Math.abs(asked.d - applied.d) > 0.05) {
showToast(L("B06_Cross_Revet_Limit_Up"), "info");
}
}
// 종단경사 반영은 **3D 전용** 스위치다(2026-08-24 사용자 확인). 횡단도는 언제나
// 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서
// 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적).
@@ -492,6 +539,51 @@ export function computeCardCulvert(
return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout;
}
/**
* **수평(Z 일원화) 보정**(2026-08-30 사용자 확정) — 경사를 풀면 기슭막이는 종단을 타지
* 않고 눕는다. 배관 벽 자리 규칙이 `상단 = 노견 d/1.2`(`placePipeWall`)이므로 이 측점
* d를 `소유 d + 1.2 × dz`로 밀면 상단 표고가 소유 측점 값 그대로가 된다. 계획고 차 dz는
* **노견 확장 구간**이 흡수하고 성토선 각도(1:1.2)는 그대로다.
*
* 다단은 손대지 않는다 — 시작점이 기준벽 하단이라 기준벽을 따라 통째로 딸려 온다.
* d가 음수로 내려가면(벽이 이 측점 노견보다 위) 기하가 0에서 자른다.
*/
function levelToOwner(adjusts: WallAdjustSet, dz: number): WallAdjustSet {
if (!adjusts || Math.abs(dz) < 1e-9) return adjusts;
const shift = (adjust: WallAdjust): WallAdjust => ({
...adjust,
d: (adjust.d ?? 0) + FILL_SLOPE_RATIO_MIN * dz,
});
return { ...adjusts, inlet: shift(adjusts.inlet), outlet: shift(adjusts.outlet) };
}
/** 한 측점의 4축 조작값 한 벌(기준벽 2 + 다단). */
type WallAdjustSet =
| { inlet: WallAdjust; outlet: WallAdjust; extras: WallAdjust[]; basinExtras: WallAdjust[] }
| undefined;
/**
* **연동을 푼 측점**의 4축(2026-08-30 사용자 확정: "연동 해제는 분리가 아니다").
* 단 수·형식은 소유 측점 것 그대로이고, 이 측점이 **손댄 벽만** 자기 값으로 바뀐다.
* 종전에는 이 측점 값(대개 빈 값)으로 통째로 다시 풀어 다단이 통째로 사라졌다
* (2026-08-30 화면 실측: 벽 4 → 2).
*/
function detachedAdjusts(
section: CrossSection,
base: WallAdjustSet,
revetOffset?: RevetOffsetControl,
): WallAdjustSet {
if (!base) return base;
const pick = (role: string, fallback: WallAdjust): WallAdjust =>
revetOffset?.storedAdjustFor(section, role as RevetKey) ?? fallback;
return {
inlet: pick("inlet", base.inlet),
outlet: pick("outlet", base.outlet),
extras: base.extras.map((adjust, i) => pick(`extra${i}`, adjust)),
basinExtras: base.basinExtras.map((adjust, i) => pick(`bextra${i}`, adjust)),
};
}
function adjustsInput(
section: CrossSection,
revetOffset?: RevetOffsetControl,
@@ -408,11 +408,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
optionParts.controls.append(button);
return { button, set };
};
const linkToggle = makeToggle(
"연동",
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.",
(on) => deps.setLinked(on),
);
const LINK_TITLE =
"옆 측점 기슭막이와 횡단도 위치를 함께 쓴다. 끄면 이 측점 위치를 따로 잡는다 — 구조물이 늘지는 않는다.";
/** 경사 해제(수평) 중에는 연동을 못 푼다 — 기슭막이 Z를 하나로 묶기 때문(2026-08-30). */
const LINK_LOCKED_TITLE =
"경사 해제(수평) 중에는 연동을 풀 수 없다 — 기슭막이 Z를 하나로 묶는다.";
const linkToggle = makeToggle("연동", LINK_TITLE, (on) => deps.setLinked(on));
const gradeToggle = makeToggle(
"경사",
"종단경사 반영(3D 전용) — 켜면 3D 기슭막이가 종단경사를 따라 서고, 끄면 연장 구간 내내 Z를 동일하게 본다. 횡단도는 언제나 그 측점 계획고로 그린다.",
@@ -575,8 +576,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
const linkState = deps.linkState();
linkToggle.button.classList.toggle("is-hidden", linkState === null);
linkToggle.set(linkState?.linked ?? true);
// 경사 해제(수평) = 연동 잠김(2026-08-30 사용자). 버튼은 눌리지 않고 이유를 단다.
const levelled = !deps.followGrade();
linkToggle.button.disabled = levelled;
linkToggle.button.title = levelled ? LINK_LOCKED_TITLE : LINK_TITLE;
gradeToggle.button.classList.toggle("is-hidden", currentSpan === null);
gradeToggle.set(deps.followGrade());
gradeToggle.set(!levelled);
optionParts.row.classList.toggle("is-hidden", linkState === null && currentSpan === null);
const adjust = deps.adjustFor(key);
// 정보는 항목당 한 행(2026-08-22 사용자) — 0이면 "자동", 좌우는 안/바깥,
+28 -7
View File
@@ -70,6 +70,9 @@ export { crossCardNaturalHeight } from "./B06_Section_UI_Cross_View_Metrics";
*/
export interface CrossCardElement extends HTMLElement {
applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void;
/** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자).
* 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */
applyRevetHighlight?: (key: RevetKey | null) => void;
}
/**
@@ -181,6 +184,9 @@ export function createCrossSectionCard(
let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined;
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
/** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 —
* 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */
let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet;
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
@@ -222,9 +228,11 @@ export function createCrossSectionCard(
const toggleRevet = (key: RevetKey): void => {
const wasSelected = isSelected;
activeRevet = activeRevet === key ? null : key;
setRevetActive(activeRevet);
showRevetControl(activeRevet !== null);
// 강조는 **연동된 카드 전부**라 제어기를 거친다(제어기가 목록에 다시 칠하기를
// 알린다 — 2026-08-30 사용자). 배선이 없어도 이 카드만은 즉시 맞춘다.
revetOffset?.select(section.chainage_m, activeRevet);
card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? activeRevet);
if (activeRevet !== null && activeArea !== null) {
activeArea = null;
setBandActive(null);
@@ -247,13 +255,20 @@ export function createCrossSectionCard(
setChipActive(activeArea);
// 면적이 켜졌거나 카드가 선택에서 빠지면 구조물 선택은 풀린다(같은 레벨).
if ((activeArea !== null || !nextSelected) && activeRevet !== null) {
// 전역 선택이 **아직 이 카드 것일 때만** 비운다 — 옆 카드에서 방금 고른 벽까지
// 지우면 그리로 옮겨 간 조정창이 곧바로 닫힌다(2026-08-30 링크 강조).
const owns = revetOffset?.selectedFor(section) === activeRevet;
activeRevet = null;
setRevetActive(null);
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
if (owns) revetOffset?.select(section.chainage_m, null);
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
}
if (activeArea !== null || !nextSelected) bodies.clearSelection();
};
card.applyRevetHighlight = (key) => {
highlightRevet = key;
setRevetActive(key);
};
const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) {
// 아직 선택되지 않은 카드 — 부모가 카드를 고르면서 이 면적까지 한 번에 켠다.
@@ -479,7 +494,7 @@ export function createCrossSectionCard(
baseSetActive(key);
setOwnActive(key === "own");
};
if (activeRevet === "own") setOwnActive(true);
if (highlightRevet === "own") setOwnActive(true);
}
}
appendCrossDesignOverlay(
@@ -548,7 +563,7 @@ export function createCrossSectionCard(
// 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙.
isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true,
);
if (activeRevet) setRevetActive(activeRevet);
if (highlightRevet) setRevetActive(highlightRevet);
}
}
@@ -662,8 +677,14 @@ export function createCrossSectionCard(
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
? culvertLink.source
: section;
const adjustOf = (key: RevetKey): WallAdjust =>
revetOffset?.adjustFor(adjustTarget(), key) ?? { x: 0, d: null, h: null, m: null };
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
const adjustOf = (key: RevetKey): WallAdjust => {
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
if (stored) return stored;
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
};
const panel = buildStructurePanel(
structurePanelDeps({
section,
@@ -24,7 +24,12 @@ import type {
StructureSpanControl,
} from "./B06_Section_UI_Cross_View";
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
import { culvertOwnerFor, tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import {
culvertOwnerFor,
culvertReach,
tierSpanOf,
wallStandsAt,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
import { createLinkFlagSession } from "./B06_Section_UI_Page_Link_Session";
@@ -184,13 +189,15 @@ export function createStationControls(deps: StationControlDeps): StationControls
* 만진 값은 그 세션의 표시 조정으로 본다. 키는 `누가거리:역할`.
* 구 형식(숫자 = x 이동량)도 읽어 준다. `select`는 다시 그리지 않는다(줌·팬 보존). */
const revetShifts = new Map<string, WallAdjust>();
const revetSelected = new Map<string, RevetKey>();
/** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편:
* 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */
let revetSelection: { at: string; key: RevetKey } | null = null;
const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`;
const revetSessionKey = (): string | null => deps.sessionKey("revetx");
function loadRevetShifts(): void {
revetShifts.clear();
revetSelected.clear();
revetSelection = null;
const key = revetSessionKey();
if (!key) return;
try {
@@ -255,17 +262,11 @@ export function createStationControls(deps: StationControlDeps): StationControls
deps.refreshCard(chainageM);
const sections = deps.detail()?.cross_sections ?? [];
const owner = sections.find((s) => Math.abs(s.chainage_m - chainageM) < 0.01);
const spec = owner?.culvert;
if (!spec) return;
const reach = Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
);
if (!owner) return;
// 연장은 그리기·링크 판정과 **같은 함수**로 잰다 — 다단 단별 구간값까지 본다.
const span = owner.culvert ? culvertReach(owner) : null;
if (!span) return;
const reach = Math.max(span.beforeM, span.afterM);
for (const other of sections) {
if (other === owner) continue;
if (Math.abs(other.chainage_m - chainageM) <= reach + 1e-9)
@@ -281,10 +282,29 @@ export function createStationControls(deps: StationControlDeps): StationControls
(section.design?.revet_adjust?.[role]
? { ...ZERO_ADJUST, ...section.design.revet_adjust[role] }
: { ...ZERO_ADJUST }),
selectedFor: (section) => revetSelected.get(section.chainage_m.toFixed(2)) ?? null,
storedAdjustFor: (section, role) => {
const session = revetShifts.get(revetKey(section.chainage_m, role));
if (session) return session;
const stored = section.design?.revet_adjust?.[role];
return stored ? { ...ZERO_ADJUST, ...stored } : null;
},
selectedFor: (section) =>
revetSelection?.at === section.chainage_m.toFixed(2) ? revetSelection.key : null,
// 강조는 **같은 구조물**을 그리는 카드 전부. 고른 카드의 소유 측점과 이 카드의
// 소유 측점이 같고, 그 벽이 이 측점에도 서면 켠다(2026-08-30 사용자).
highlightFor: (section) => {
if (!revetSelection) return null;
if (revetSelection.at === section.chainage_m.toFixed(2)) return revetSelection.key;
const picked = sectionAt(Number(revetSelection.at));
if (!picked) return null;
const owner = picked.culvert ? picked : ownerOf(picked);
const here = section.culvert ? section : ownerOf(section);
if (!owner || !here || Math.abs(here.chainage_m - owner.chainage_m) > 0.01) return null;
return wallStandsAt(owner, section, revetSelection.key) ? revetSelection.key : null;
},
select: (chainageM, key) => {
if (key) revetSelected.set(chainageM.toFixed(2), key);
else revetSelected.delete(chainageM.toFixed(2));
revetSelection = key ? { at: chainageM.toFixed(2), key } : null;
revetOffsetControl.onSelectionChange?.();
},
syncApplied: (chainageM, role, applied) => {
// 기하가 실제로 적용한 값(한계 절삭 후)을 되받아 담는다 — 숫자만 커지는 것 방지.
@@ -650,8 +670,36 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
};
/** 종단경사 반영 여부 — 세션 → 정본 → 기본 켬. 소유 측점에 하나다. */
const followGradeOf = (section: CrossSection): boolean => {
const session = followGrades.get(chainageKey(section.chainage_m));
if (session !== undefined) return session;
return section.design?.revet_follow_grade !== false;
};
/**
* 연동 해제로 **그 측점에만** 붙어 있던 4축 값을 지운다 — 경사 해제(수평)로 넘어갈 때
* 쓴다(2026-08-30 사용자: 해제해 둔 값은 되살리지 않는다). 옛 D경로 독립 벽(`own`)은
* 이 구조물 것이 아니라 남긴다.
*/
const dropLinkedAdjusts = (chainageM: number): void => {
const prefix = `${chainageM.toFixed(2)}:`;
for (const key of [...revetShifts.keys()]) {
if (!key.startsWith(prefix) || key.slice(prefix.length) === "own") continue;
revetShifts.delete(key);
}
persistRevetShifts();
const design = sectionAt(chainageM)?.design;
if (!design?.revet_adjust) return;
const own = design.revet_adjust.own;
design.revet_adjust = own ? { own } : {};
};
const revetLinkControl: RevetLinkControl = {
linkedFor: (section) => {
// 경사 해제(수평)면 연동은 잠긴다 — 측점마다 자리를 따로 잡으면 기슭막이 Z를
// 하나로 묶을 수 없다(2026-08-30 사용자).
if (!followGradeOf(ownerOf(section) ?? section)) return true;
const session = linkDetached.get(chainageKey(section.chainage_m));
if (session !== undefined) return !session;
return section.design?.revet_link_detached !== true;
@@ -662,34 +710,26 @@ export function createStationControls(deps: StationControlDeps): StationControls
patchCachedDesign(chainageM, { revet_link_detached: !linked });
deps.refreshCard(chainageM);
},
followGradeFor: (section) => {
const session = followGrades.get(chainageKey(section.chainage_m));
if (session !== undefined) return session;
return section.design?.revet_follow_grade !== false;
},
followGradeFor: followGradeOf,
setFollowGrade: (section, follow) => {
followGrades.set(chainageKey(section.chainage_m), follow);
persistLinkFlags();
patchCachedDesign(section.chainage_m, { revet_follow_grade: follow });
// 종단경사 반영은 그 기슭막이가 덮는 측점들의 표고를 바꾼다 — 소유 측점과 그
// 연장 안의 측점만 다시 그린다.
const spec = section.culvert;
const reach = spec
? Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => {
const span =
side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM];
}),
)
: 0;
const span = section.culvert ? culvertReach(section) : null;
const reach = span ? Math.max(span.beforeM, span.afterM) : 0;
for (const other of deps.detail()?.cross_sections ?? []) {
if (Math.abs(other.chainage_m - section.chainage_m) <= reach + 1e-9) {
deps.refreshCard(other.chainage_m);
if (Math.abs(other.chainage_m - section.chainage_m) > reach + 1e-9) continue;
// 수평으로 넘어가면 연동 해제를 되돌리고 그 측점만의 자리도 지운다
// (2026-08-30 사용자: 경사를 다시 켜도 되살아나지 않는다).
if (!follow && Math.abs(other.chainage_m - section.chainage_m) > 0.01) {
linkDetached.set(chainageKey(other.chainage_m), false);
patchCachedDesign(other.chainage_m, { revet_link_detached: false });
dropLinkedAdjusts(other.chainage_m);
}
deps.refreshCard(other.chainage_m);
}
persistLinkFlags();
},
};
@@ -331,6 +331,22 @@ export function createSectionView(
card?.applySelection?.(stationId === selectedStationId, activeAreaKey);
};
/**
* 연동으로 이어진 카드들의 **벽 강조**를 다시 칠한다(2026-08-30 사용자: 한 기슭막이는
* 그 연장 안 모든 횡단도에서 같이 강조된다). 조정창은 고른 카드 하나에만 남는다.
* 카드는 다시 만들지 않고 클래스만 갈아 끼운다 — 줌·팬 보존.
*/
const syncRevetHighlights = (): void => {
if (!currentDetail || !revetOffset) return;
for (const section of currentDetail.cross_sections) {
const card = document.getElementById(
`cross-${section.station_id}`,
) as CrossCardElement | null;
card?.applyRevetHighlight?.(revetOffset.highlightFor(section));
}
};
if (revetOffset) revetOffset.onSelectionChange = syncRevetHighlights;
/**
* 선택이 바뀐 흔적만 화면에 반영한다 — **재렌더 금지**.
* 카드를 새로 만들면(전체든 한 장이든) 사용자가 맞춰 둔 휠 줌·팬(viewBox)이 초기화된다