diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 110edf0..792f566 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -405,6 +405,13 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { }; // Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink. const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]); + // A step at the kink (2026-11-10): each stretch's step face is square to its own axis, the mitre is not, so the face + // moves past the mitre spread (B/2)·tan(θ/2) into the trimmed side: stretch 0 keeps its left top up to the mitre and + // stretch 1 its right top back to it, and the visible step is the mitre plane itself (no sliver on either half). + const mitreSpread = (B / 2) * Math.tan((Math.abs(ip4.angleDeg) * Math.PI) / 360) + 0.01; + const atKink = (pts: [number, number][], k: number): [number, number][] => + kinkG === null || mitreSpread >= halfBlock ? pts + : pts.map(([s, z]) => [Math.abs(s - kinkG) < 1e-9 ? kinkG + (k === 0 ? mitreSpread : -mitreSpread) : s, z]); const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length; const copingParts: CopingPart[] = []; @@ -424,11 +431,11 @@ export function buildPier(input: PierInput, ctx: PierContext = {}): PierModel { // side of its own row; the order stays [front, back]. const frontLow = (fRows.length && bRows.length ? meanN(fRows) - meanN(bRows) : meanN(frontPlaced) - meanN(backPlaced)) <= 0; copingParts.push( - { k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(outline(segments), base[k]) }, - { k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(outline(backSegments), base[k]) }, + { k, nFrom: frontLow ? -planHalfB : nStep, nTo: frontLow ? nStep : planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) }, + { k, nFrom: frontLow ? nStep : -planHalfB, nTo: frontLow ? planHalfB : nStep, profile: shifted(atKink(outline(backSegments), k), base[k]) }, ); } else { - copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(segments), base[k]) }); + copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(atKink(outline(segments), k), base[k]) }); } } diff --git a/tests/kink-steps.test.ts b/tests/kink-steps.test.ts index 0f7da7d..b72dd1a 100644 --- a/tests/kink-steps.test.ts +++ b/tests/kink-steps.test.ts @@ -89,3 +89,29 @@ describe('전·후열 나눔선 하나 (B, 2026-11-10)', () => { for (const k of [0, 1]) expect(split(m, k)).toBeCloseTo((meanN(m, '전열', k) + meanN(m, '후열', k)) / 2, 9); }); }); + +describe('꺾임점 계단면 = 미터면 (2026-11-10)', () => { + // Top of a stepped profile at s: the last top point at or before s. + const topAt = (m: ReturnType, profile: [number, number][], s: number) => + profile.slice(0, profile.findIndex(([ps]) => ps > s + 1e-9)).filter(([, z]) => z > m.endBottom + 1e-9).pop()![1]; + + it('over the whole mitre spread, stretch 0 keeps the top before the kink and stretch 1 the top after it', () => { + const m = buildPier(p11()); + const kink = kinkOf(m); + const spread = (m.copingB / 2) * Math.tan((8.767 * Math.PI) / 360); + // The back part carries the ⑥|⑦ step at the kink: ⑥ top (back segment 1) before, ⑦ top (segment 2) after. + expect(m.backSegments![2].s0).toBeCloseTo(kink, 9); + const before = m.backSegments![1].top, after = m.backSegments![2].top; + expect(after - before).toBeGreaterThan(0.5); + const backPart = (k: number) => { + const parts = m.copingParts.filter((p) => p.k === k); + return parts.find((p) => p.profile.some(([, z]) => Math.abs(z - after) < 1e-9))!; + }; + // Stretch 0 (local s = g): just short of the far edge of the mitre spread, still the ⑥ top. + expect(topAt(m, backPart(0).profile, kink + spread - 1e-6)).toBeCloseTo(before, 9); + // Stretch 1 (local s = g − kink): just past the near edge of the spread, already the ⑦ top. + expect(topAt(m, backPart(1).profile, -spread + 1e-6)).toBeCloseTo(after, 9); + // Away from the kink the profiles are unchanged: the ⑦ top in stretch 0 a block past the kink. + expect(topAt(m, backPart(0).profile, kink + DIM.blockSize / 2 + 0.1)).toBeCloseTo(after, 9); + }); +});