diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts index 141eb76e7..63cf7b044 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts @@ -12,6 +12,8 @@ * 보간 단면(제어점 · `_Frames.ts`) · 단면 밖은 마지막 구배 연장. * · 성토 · 절토는 앵커에서 이어진 자리(첫 지반 교차점 안)에만 — 비탈 끝 밖은 원지반. * 못 찾으면 단면 끝까지만(115-5 P0-1) · 높이 변화는 단면 높이 + 1 m 까지. + * · 성토 비탈 끝은 종단으로 잇는다(115-7 smoothFillCatches) — 튐 · 못 닫힘 보간 · 원뿔 넓히기 · + * 바뀐 행은 비탈 끝 밖 2 m 덧댐. * · 조각은 앞뒤 측점 중 하나라도 가진 행에만(`_Build.ts` 와 같은 규칙). * · XY 는 움직이지 않는다. * ========================================================================== */ @@ -50,6 +52,15 @@ const GRID_M = 4; const SEED_EVERY = 5; const WALK_LIMIT = 400; +// ponytail: 성토 비탈 끝 종단 이음(115-7) 고정 값 — 이웃 ±3 m 중앙값 · 튐 = max(2 m, 25 %) · +// 원뿔 1 m/m · 비탈 끝 밖 덧댐 2 m · 이을 짧은 틈 3 m. 지형마다 안 맞으면 여기서 조절. +const FILL_WINDOW_M = 3; +const FILL_JUMP_M = 2; +const FILL_JUMP_RATIO = 0.25; +const FILL_CONE = 1; +const FILL_SKIRT_M = 2; +const FILL_BRIDGE_M = 3; + const SIDES: readonly DeformSide[] = ["left", "right"]; const KINDS: readonly DeformKind[] = ["fill", "cut"]; @@ -290,6 +301,105 @@ export function stationProfileSource( }; } +/** + * 성토 비탈 끝 종단 이음(115-7) — 한 측 성토 열. 행마다 따로 찾은 비탈 끝이 이웃과 튀면(골짜기 · + * 계단 DTM · 못 닫힘) 두 행 혼합이 행 사이에 수직 벽(지느러미 · 빗살 구덩이)을 세운다. + * 튐 · 못 닫힘 행 → 이웃 좋은 행 보간 → 원뿔 넓히기(이웃 차 1 m/m 안). 측점 행은 날것(못 닫힘 빼고). + * 비탈 끝 0 행은 구간을 끊는다 — 단면은 있는데 앵커에서 막힌 3 m 안 틈만 잇는다. + * `open` = 못 닫힘 · `station` = 측점 행 · `shaped` = 그 행에 성토 단면 있음(모두 0/1). + */ +export function smoothFillCatches( + chainages: ArrayLike, + raw: ArrayLike, + open: ArrayLike, + station: ArrayLike, + shaped: ArrayLike, +): Float64Array { + const n = raw.length; + const out = Float64Array.from(raw); + const on = new Uint8Array(n); + const bridged = new Uint8Array(n); + for (let r = 0; r < n; r += 1) on[r] = raw[r] > 0 ? 1 : 0; + for (let r = 1; r < n; r += 1) { + if (on[r] || !on[r - 1]) continue; + let e = r; + while (e < n && !on[e] && shaped[e]) e += 1; + if (e < n && on[e] && chainages[e - 1] - chainages[r] <= FILL_BRIDGE_M) { + for (let k = r; k < e; k += 1) on[k] = bridged[k] = 1; + } + r = e; + } + for (let start = 0; start < n;) { + if (!on[start]) { + start += 1; + continue; + } + let end = start; + while (end + 1 < n && on[end + 1]) end += 1; + const trusted = (k: number) => !open[k] && !bridged[k]; + const good = new Uint8Array(end - start + 1); + let lo = start; + let hi = start; + for (let r = start; r <= end; r += 1) { + if (station[r] && trusted(r)) { + good[r - start] = 1; + continue; + } + while (chainages[lo] < chainages[r] - FILL_WINDOW_M) lo += 1; + while (hi < end && chainages[hi + 1] <= chainages[r] + FILL_WINDOW_M) hi += 1; + const near: number[] = []; + for (let k = lo; k <= hi; k += 1) if (trusted(k)) near.push(raw[k]); + if (!near.length) { + good[r - start] = trusted(r) ? 1 : 0; + continue; + } + near.sort((a, b) => a - b); + const median = near[near.length >> 1]; + const jump = Math.max(FILL_JUMP_M, median * FILL_JUMP_RATIO); + good[r - start] = trusted(r) && Math.abs(raw[r] - median) <= jump ? 1 : 0; + } + // 튐 · 못 닫힘 — 앞뒤 좋은 행을 노선 길이로 보간(한쪽뿐이면 그 값 · 없으면 날것). + let prev = -1; + for (let r = start; r <= end; r += 1) { + if (good[r - start]) { + prev = r; + continue; + } + let next = r + 1; + while (next <= end && !good[next - start]) next += 1; + if (prev >= 0 && next <= end) { + const t = (chainages[r] - chainages[prev]) / (chainages[next] - chainages[prev] || 1); + out[r] = raw[prev] + (raw[next] - raw[prev]) * t; + } else if (prev >= 0) out[r] = raw[prev]; + else if (next <= end) out[r] = raw[next]; + } + // 원뿔 넓히기 — c'_r = max_j(c_j − 1·|s_r − s_j|) 를 앞뒤 두 번 훑어 낸다. 늘린 몫은 측점 닻 + // (측점 값 + 1 m/m)까지만 — 먼 큰 값이 측점까지 번지지 않게. 보간 값보다 줄이지는 않는다. + const base = out.slice(start, end + 1); + const cap = new Float64Array(end - start + 1).fill(Infinity); + for (let r = start + 1; r <= end; r += 1) { + out[r] = Math.max(out[r], out[r - 1] - FILL_CONE * (chainages[r] - chainages[r - 1])); + } + for (let r = end - 1; r >= start; r -= 1) { + out[r] = Math.max(out[r], out[r + 1] - FILL_CONE * (chainages[r + 1] - chainages[r])); + } + for (const step of [1, -1]) { + let anchor = -1; + for (let r = step > 0 ? start : end; r >= start && r <= end; r += step) { + if (station[r] && trusted(r)) anchor = r; + if (anchor < 0) continue; + const reach = raw[anchor] + FILL_CONE * Math.abs(chainages[r] - chainages[anchor]); + cap[r - start] = Math.min(cap[r - start], reach); + } + } + for (let r = start; r <= end; r += 1) { + out[r] = Math.max(base[r - start], Math.min(out[r], cap[r - start])); + } + start = end + 1; + } + return out; +} + /** 점이 고리 안인가(광선 투사). */ function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean { let hit = false; @@ -325,6 +435,8 @@ export function buildDeformField( const heights = new Float64Array(n * 4); const bends: Array> = new Array(n * 4); const noExtend = new Uint8Array(n * 2); + /** 비탈 끝을 단면 끝까지 못 찾음(못 닫힘). */ + const open = new Uint8Array(n * 4); let reachMax = 0; rows.forEach((row, r) => { const dx = row.left[0] - row.right[0]; @@ -379,22 +491,62 @@ export function buildDeformField( previous = now; } // 못 찾으면 단면 끝까지만 — 마지막 구배로 60 m 까지 깎던 폭주 구덩이(115-5 P0-1 ①). - if (!found) reach = profile[(count - 1) * 2]; + const end = profile[(count - 1) * 2]; + if (!found) reach = end; + // 못 닫힘(115-7) — 단면 끝(측점 = B06 비탈 끝) 너머 튐 폭보다 멀리서야 닫힌 성토. 연장 구배를 + // 안전 상한이 눌러 원지반 위 띠(지느러미)가 되던 행 — 이웃 좋은 행으로 보간한다. + const slack = Math.max(FILL_JUMP_M, end * FILL_JUMP_RATIO); + if (!found || (kind === "fill" && reach > end + slack)) open[slot] = 1; } catches[slot] = reach; - if (reach > reachMax) reachMax = reach; - // 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만. - for (let i = 1; i < count - 1; i += 1) { - const s = profile[i * 2]; - if (s >= reach) break; - const a0 = Math.atan2(profile[i * 2 + 1] - profile[i * 2 - 1], s - profile[i * 2 - 2]); - const a1 = Math.atan2(profile[i * 2 + 3] - profile[i * 2 + 1], profile[i * 2 + 2] - s); - if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) bends[slot].push([i, s]); - } }); }); }); + // 성토 비탈 끝 종단 이음(115-7) — 짧아진 행 · 못 닫힘 행은 비탈 끝 밖을 덧댄다. + const chainages = rows.map((row) => row.chainage_m); + const station = new Uint8Array(n); + for (const section of build.sections ?? []) { + let r = 0; + while (r + 1 < n && chainages[r + 1] <= section.chainage_m) r += 1; + if (r + 1 < n && chainages[r + 1] - section.chainage_m < section.chainage_m - chainages[r]) { + r += 1; + } + if (Math.abs(chainages[r] - section.chainage_m) < 0.15) station[r] = 1; + } + const skirt = new Uint8Array(n * 4); + for (let si = 0; si < 2; si += 1) { + const pick = (of: (slot: number) => T) => rows.map((_, r) => of(r * 4 + si * 2)); + const raw = pick((slot) => catches[slot]); + const final = smoothFillCatches( + chainages, + raw, + pick((slot) => open[slot]), + station, + pick((slot) => (profiles[slot] ? 1 : 0)), + ); + final.forEach((c, r) => { + const slot = r * 4 + si * 2; + catches[slot] = c; + if (c > 0 && (open[slot] || c < raw[r] - 0.01 || raw[r] <= 0)) skirt[slot] = 1; + }); + } + for (let slot = 0; slot < n * 4; slot += 1) { + const reach = catches[slot]; + reachMax = Math.max(reachMax, reach + (skirt[slot] ? FILL_SKIRT_M : 0)); + const profile = profiles[slot]; + if (!profile) continue; + const count = profile.length / 2; + // 단면 꺾임(암/토사 · 소단) — 비탈 끝 안쪽만. + for (let i = 1; i < count - 1; i += 1) { + const s = profile[i * 2]; + if (s >= reach) break; + const a0 = Math.atan2(profile[i * 2 + 1] - profile[i * 2 - 1], s - profile[i * 2 - 2]); + const a1 = Math.atan2(profile[i * 2 + 3] - profile[i * 2 + 1], profile[i * 2 + 2] - s); + if (Math.abs(a1 - a0) * (180 / Math.PI) > BEND_DEG) bends[slot].push([i, s]); + } + } + // 행 중심 격자 — 칸마다 가장 가까운 씨앗 행. 정점은 그 행에서 이웃으로 걸어 자리를 잡는다. let maxHalf = 0; hw.forEach((value) => (maxHalf = Math.max(maxHalf, value))); @@ -482,10 +634,15 @@ export function buildDeformField( KINDS.forEach((kind, ki) => { const slot = r * 4 + si * 2 + ki; const profile = profiles[slot]; - if (!profile || s >= catches[slot]) return; - const rel = profileZ(profile, s, free); + const reach = catches[slot]; + if (!profile || reach <= 0) return; + // 덧댐(115-7) — 이은 비탈 끝 밖 2 m 는 비탈 끝 높이에서 원지반으로 내려 수직 면을 없앤다. + if (s >= reach && (!skirt[slot] || s >= reach + FILL_SKIRT_M)) return; + const pad = s < reach ? 0 : (s - reach) / FILL_SKIRT_M; + const rel = profileZ(profile, Math.min(s, reach), free); if (rel === null) return; let line = anchor[2] + rel; + line += (z - line) * pad; // 안전 상한(115-5 P0-1 ④) — 단면 높이 + 1 m 넘게는 옮기지 않는다. 구조물에서 끝나는 // 단면(BOX)은 지반까지 안 내려가 단면 높이가 기준이 못 된다. const cap = heights[slot] + SAFETY_M; diff --git a/resources/tester/helper_115_7_fill_continuity.cjs b/resources/tester/helper_115_7_fill_continuity.cjs new file mode 100644 index 000000000..52cad502d --- /dev/null +++ b/resources/tester/helper_115_7_fill_continuity.cjs @@ -0,0 +1,210 @@ +/* PLAN 115-7 단위검증 헬퍼 — B05 3D 성토 비탈 끝 종단 이음(공통 변형 장 · 새 ① · 제안 ②). + * TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. 결과는 표준출력에 JSON 한 벌. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const THREE = require(path.join(ROOT, "config", "node_modules", "three")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request === "three") return THREE; + if (request.startsWith("@ui/") || request.startsWith("@config/")) { + return new Proxy({}, { get: () => () => undefined }); + } + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const deform = loadTs(B05("B05_Profile_UI_Corridor_Deform")); + +const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 10000); +const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) +const ROAD_Z = 100; + +/** x 축 직선 노선 측점 — 좌 성토(1:1.5 · 4 m 아래 · 비탈 끝 9) · 우 절토(1:1 · 4 m 위 · 비탈 끝 −7). */ +function station(chainage) { + const pts = [ + [-7, 104], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [9, 96], + ]; + const samples = []; + for (let o = -30; o <= 30; o += 1) samples.push({ offset_m: o, elevation_m: o > 0 ? 96 : 104 }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: chainage, + center_y: 0, + center_z: ROAD_Z, + frame: { left_xy: [0, 1] }, + samples, + design: { + section_mode: "right_cut", + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +const SECTIONS = [0, 20, 40, 60, 80, 100].map(station); + +function fieldOf(groundAt) { + const build = buildCorridor(SECTIONS, [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + ]); + build.sections = SECTIONS; + const field = deform.buildDeformField(build, deform.stationProfileSource(SECTIONS), groundAt); + return field; +} + +/** 행 · 측 성토 비탈 끝 열(노선 길이 순). */ +function catches(field, si, ki) { + return field.rows.map((row, r) => ({ ch: row.chainage_m, c: field.catchAt(r, si, ki) })); +} + +/** 지느러미 행 — 성토 행끼리 ±3 m 중앙값 + 3 m 넘음. */ +function fins(list) { + let count = 0; + for (const { ch, c } of list) { + if (c <= 0) continue; + const near = list + .filter((o) => o.c > 0 && Math.abs(o.ch - ch) <= 3) + .map((o) => o.c) + .sort((a, b) => a - b); + if (c > near[near.length >> 1] + 3) count += 1; + } + return count; +} + +const wobble = (list) => { + let sum = 0; + for (let i = 1; i < list.length; i += 1) { + if (list[i].c > 0 && list[i - 1].c > 0) sum += Math.abs(list[i].c - list[i - 1].c); + } + return (sum / (list[list.length - 1].ch - list[0].ch)) * 100; +}; + +const results = {}; + +// ① 도로에 직각인 골짜기 — 좁은 골(49~51 m) · 넓은 골(64~72 m)에서 지반이 3 m 더 깊다. 옛 규칙은 +// 골 행만 연장 구배로 10.5 m 까지 뻗어 지느러미 · 수직 벽. 새 규칙은 이웃 좋은 행(6 m)으로 보간 + +// 비탈 끝 밖 2 m 덧댐. 우측 절토는 같은 골을 그대로 따른다(절토는 안 바꿈). +{ + const gully = (x) => (x > 49 && x < 51) || (x > 64 && x < 72); + const groundAt = (x, y) => { + if (y >= 0) return gully(x) && y > 5 ? 93 : 96; + return gully(x) && y < -5 ? 101 : 104; + }; + const field = fieldOf(groundAt); + const fill = catches(field, 0, 0); + const inGully = fill.filter((o) => gully(o.ch)); + const cutGully = catches(field, 1, 1).filter((o) => gully(o.ch) && o.ch > 50 && o.ch < 50.5); + const zAt = (x, s) => field.deformZ(x, HALF + s, groundAt(x, HALF + s)); + results.valley = { + fins: fins(fill), + gullyMax: r4(Math.max(...inGully.map((o) => o.c))), + gullyMin: r4(Math.min(...inGully.map((o) => o.c))), + skirtMid: r4(zAt(68, 7)), // 비탈 끝(6) 밖 1 m — 96 에서 93 으로 반쯤 + skirtOut: r4(zAt(68, 8.5)), // 덧댐 밖 — 원지반 그대로 + outside: r4(zAt(30, 8)), // 골 밖 비탈 끝 밖 — 원지반 + cutGully: r4(Math.max(...cutGully.map((o) => o.c))), + cutFlat: r4( + field.catchAt( + field.rows.findIndex((row) => row.chainage_m >= 30), + 1, + 1, + ), + ), + }; +} + +// ② 계단 지형 — 1 m 띠마다 지반이 0.6 m 오르내린다(비탈 끝 6 ↔ 5.1). 원뿔 넓히기로 출렁임이 준다. +{ + const step = (x) => (Math.floor(x) % 2 ? 0.6 : 0); + const groundAt = (x, y) => (y >= 0 ? 96 + step(x) : 104); + const field = fieldOf(groundAt); + const fill = catches(field, 0, 0).filter((o) => o.ch > 1 && o.ch < 99); + // 날것 출렁임 — 띠 경계마다 0.9 m(6 − 5.1) 씩. + const raw = fill.map((o) => ({ ch: o.ch, c: step(o.ch) ? 5.1 : 6 })); + results.steps = { wobble: r4(wobble(fill)), rawWobble: r4(wobble(raw)), fins: fins(fill) }; +} + +// ③ ④ ⑤ 열 단위 — 못 닫힘 보간 · 측점 행 날것 · 0 행 끊음 / 짧은 틈 이음. +{ + const n = 101; + const ch = Array.from({ length: n }, (_, i) => i * 0.2); + const raw = new Array(n).fill(6); + const open = new Array(n).fill(0); + const station = new Array(n).fill(0); + const shaped = new Array(n).fill(1); + for (let i = 20; i <= 24; i += 1) { + raw[i] = 15; + open[i] = 1; + } + station[50] = 1; + raw[50] = 9; // 측점 행 — 튀어도 날것 + station[60] = 1; + raw[60] = 20; + open[60] = 1; // 못 닫힘 측점 — 보간 + for (let i = 70; i <= 72; i += 1) raw[i] = 0; // 짧은 틈(0.4 m) — 단면 있음 → 잇는다 + for (let i = 80; i <= 82; i += 1) { + raw[i] = 0; + shaped[i] = 0; // 단면 없음 → 끊음 + } + const out = deform.smoothFillCatches(ch, raw, open, station, shaped); + const gap = []; + for (let i = 0; i < n; i += 1) if (raw[i] === 0 && shaped[i] === 0) gap.push(out[i]); + results.column = { + openMax: r4(Math.max(...out.slice(20, 25))), + stationKept: r4(out[50]), + stationOpen: r4(out[60]), + bridged: r4(Math.min(...out.slice(70, 73))), + cutGap: r4(Math.max(...gap)), + neighbourStep: r4( + Math.max( + ...out + .slice(1) + .map((c, i) => (out[i] > 0 && c > 0 ? Math.abs(c - out[i]) / 0.2 : 0)) + .filter((_, i) => i < 49 || i > 51), + ), + ), + }; +} + +process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_7_fill_continuity.py b/resources/tester/test_115_7_fill_continuity.py new file mode 100644 index 000000000..e1c4a19bd --- /dev/null +++ b/resources/tester/test_115_7_fill_continuity.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""PLAN 115-7 — B05 3D 성토 비탈 끝 종단 이음(공통 변형 장) 합성 검사. + +Node 도우미(helper_115_7_fill_continuity.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 +결과(JSON)를 단언한다. 직각 골짜기 지느러미 0 · 덧댐 · 계단 지형 출렁임 · 못 닫힘 보간 · +측점 행 날것 · 0 행 끊음 · 절토 그대로. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_115_7_fill_continuity.cjs")], + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_valley_has_no_fins(results): + r = results["valley"] + assert r["fins"] == 0 + assert ( + r["gullyMax"] == 6 and r["gullyMin"] == 6 + ) # 골 행도 이웃 비탈 끝(6 m) — 10.5 m 로 안 뻗음 + + +def test_valley_skirt_replaces_wall(results): + r = results["valley"] + assert r["skirtMid"] == 94.5 # 비탈 끝 밖 1 m — 96 → 93 반쯤 + assert r["skirtOut"] == 93 # 덧댐 밖 — 원지반 + assert r["outside"] == 96 # 골 밖 — 원지반 그대로 + + +def test_cut_unchanged(results): + r = results["valley"] + assert r["cutFlat"] == 4 + assert 2 < r["cutGully"] < 2.3 # 절토는 골을 날것 그대로 따른다 + + +def test_step_terrain_wobble_small(results): + r = results["steps"] + assert r["fins"] == 0 + assert r["wobble"] < 0.7 * r["rawWobble"] + + +def test_open_rows_interpolated(results): + r = results["column"] + assert r["openMax"] == 6 # 못 닫힘(15) → 이웃 6 + assert r["stationOpen"] < 9 # 못 닫힘 측점도 보간(날것 20 아님) + + +def test_station_row_kept_and_zero_breaks(results): + r = results["column"] + assert r["stationKept"] == 9 # 측점 행 날것 + assert r["bridged"] == 6 # 단면 있는 짧은 틈 — 잇는다 + assert r["cutGap"] == 0 # 단면 없는 행 — 0 그대로(구간 끊음) + assert r["neighbourStep"] <= 1 + 1e-6 # 이웃 행 차 1 m/m 안