From f775251593178321562a66ae3adfee9443b24be5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 19 Oct 2026 02:45:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EB=85=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=EC=84=A0=EA=B3=BC=20=ED=8A=B8=EB=9E=99=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=ED=82=A4=20=EA=B8=B0=EC=A4=80=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/shared/useLayoutComputation.test.ts | 260 +++++++++++++++++- .../hooks/shared/useLayoutComputation.ts | 81 ++++-- src/renderer/windows/overlay/App.tsx | 10 +- 3 files changed, 321 insertions(+), 30 deletions(-) diff --git a/src/renderer/hooks/shared/useLayoutComputation.test.ts b/src/renderer/hooks/shared/useLayoutComputation.test.ts index 85985fdb..3e1c939d 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.test.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.test.ts @@ -134,21 +134,35 @@ describe('computeLayout 스프라이트 오버행', () => { // 배경 박스는 콘텐츠 바운즈 기준 위치·크기 유지 expect(layout.backgroundBox?.x).toBeCloseTo(41.4214, 3); - expect(layout.backgroundBox?.y).toBeCloseTo(41.4214, 3); expect(layout.backgroundBox?.width).toBe(260); expect(layout.backgroundBox?.height).toBe(410); // 아이템 오프셋은 창 바운즈 기준이라 콘텐츠가 여유만큼 안쪽으로 밀린다 expect(layout.positionOffset.x).toBeCloseTo(30 - 58.5786, 3); - expect(layout.positionOffset.y).toBeCloseTo(180 - 8.5786, 3); expect(layout.displaySpritePositions[0].dx).toBeCloseTo(71.4214, 3); - - // 트랙 시작은 콘텐츠 상단을 따라간다 - expect(layout.topMostY).toBeCloseTo(221.4214, 3); - // 왼쪽 오버행도 같은 방식 - 네이티브 x 보정이 이 delta를 소비한다 + // 위쪽 오버행 41.42는 트랙 밴드(150) 안이라 창을 위로 키우지 않는다 + expect(layout.positionOffset.y).toBe(130); + expect(layout.backgroundBox?.y).toBe(0); + expect(layout.topMostY).toBe(180); + // 왼쪽 오버행 - 네이티브 x 보정이 이 delta를 소비한다 expect(layout.leftMostX).toBeCloseTo(71.4214, 3); }); + it('트랙 밴드를 넘는 위쪽 오버행만 창을 위로 키우고 원점 보정 delta가 된다', () => { + const layout = computeLayout({ + ...makeInput([ + makeSprite({ idleTransform: makeTransform({ rotation: 45 }) }), + ]), + trackHeight: 20, + }); + // 밴드 상단 30보다 오버행 상단 8.58이 더 위 + expect(layout.positionOffset.y).toBeCloseTo(30 - 8.5786, 3); + expect(layout.backgroundBox?.y).toBeCloseTo(21.4214, 3); + expect(layout.backgroundBox?.height).toBe(280); + // 밴드 아래 콘텐츠 상단(50)의 창 좌표가 오버행만큼 내려간다 + expect(layout.topMostY).toBeCloseTo(71.4214, 3); + }); + it('이미지 없는 스프라이트는 회전해도 창을 넓히지 않는다', () => { const layout = computeLayout( makeInput([ @@ -705,3 +719,237 @@ describe('computeLayout 회전 키 자동 시작선 세부', () => { expect(layout.contentSize?.height).toBeLessThan(1060 + 60 + 30); }); }); + +describe('computeLayout 히트라인과 비키 요소', () => { + // 노트 열은 x 0~240, 키 상변 y=300, 트랙 밴드는 y 0~300 + const makeInput = ( + extra: { + plugins?: Array<{ x: number; y: number; width: number; height: number }>; + stats?: Array<{ dx: number; dy: number; width: number; height: number }>; + noteOffsetYA?: number; + hideKeys?: boolean; + rotation?: number; + } = {}, + ) => ({ + currentKeys: ['A', 'B', 'C', 'D'], + currentPositions: ['A', 'B', 'C', 'D'].map( + (id, index) => + ({ + id, + dx: index * 60, + dy: 300, + width: 60, + height: 60, + rotation: extra.rotation ?? 0, + noteOffsetY: index === 0 ? extra.noteOffsetYA : undefined, + hidden: extra.hideKeys, + } as never), + ), + currentStatPositions: (extra.stats ?? []) as never[], + currentGraphPositions: [], + currentKnobPositions: [], + currentSpritePositions: [], + trackHeight: 300, + noteSettings: DEFAULT_NOTE_SETTINGS, + selectedKeyType: '4key', + pluginElements: (extra.plugins ?? []).map((plugin) => ({ + position: { x: plugin.x, y: plugin.y }, + measuredSize: { width: plugin.width, height: plugin.height }, + })), + }); + const tracksOf = (layout: ReturnType) => + layout.webglTracks as Array<{ position: { dx: number; dy: number } }>; + const keyTopOf = (layout: ReturnType) => + (layout.displayPositions[0] as { dy: number }).dy; + + it('키만 있으면 창은 키 + 트랙 높이 + 패딩이다', () => { + const layout = computeLayout(makeInput()); + expect(layout.contentSize).toEqual({ width: 300, height: 420 }); + expect(keyTopOf(layout)).toBe(330); + expect(tracksOf(layout)[0].position.dy).toBe(330); + }); + + it('노트 열 밖 패널은 트랙 밴드 안에 있으면 창 높이를 키우지 않는다', () => { + const layout = computeLayout( + makeInput({ plugins: [{ x: 260, y: 100, width: 200, height: 260 }] }), + ); + expect(layout.contentSize).toEqual({ width: 520, height: 420 }); + expect(keyTopOf(layout)).toBe(330); + for (const track of tracksOf(layout)) { + expect(track.position.dy).toBe(330); + } + }); + + it('노트 열 밖 패널을 밴드 안에서 옮겨도 키·시작선·창 기준점이 그대로다', () => { + const upper = computeLayout( + makeInput({ plugins: [{ x: 260, y: 50, width: 200, height: 260 }] }), + ); + const lower = computeLayout( + makeInput({ plugins: [{ x: 260, y: 150, width: 200, height: 260 }] }), + ); + expect(keyTopOf(lower)).toBe(keyTopOf(upper)); + expect(tracksOf(lower)[0].position.dy).toBe(tracksOf(upper)[0].position.dy); + expect(lower.topMostY).toBe(upper.topMostY); + expect(lower.contentOrigin).toEqual(upper.contentOrigin); + // 아래로 나간 만큼만 창이 아래로 자란다 + expect(lower.contentSize!.height - upper.contentSize!.height).toBe(50); + }); + + it('밴드 위로 나간 열 밖 패널은 나간 만큼만 창을 키우고 노트는 키에서 시작한다', () => { + const layout = computeLayout( + makeInput({ plugins: [{ x: 260, y: -100, width: 200, height: 200 }] }), + ); + expect(layout.contentSize!.height).toBe(360 + 100 + 60); + expect(keyTopOf(layout)).toBe(430); + expect(tracksOf(layout)[0].position.dy).toBe(430); + }); + + it('노트 열 밖 통계 카운터도 같은 규칙을 따른다', () => { + const layout = computeLayout( + makeInput({ stats: [{ dx: 260, dy: 100, width: 60, height: 200 }] }), + ); + expect(layout.contentSize).toEqual({ width: 380, height: 420 }); + expect(tracksOf(layout)[0].position.dy).toBe(keyTopOf(layout)); + }); + + // top-left 앵커는 topMostY delta만큼 창을 되당긴다 - 0이면 키가 화면에서 제자리 + const keyScreenShift = ( + before: ReturnType, + after: ReturnType, + ) => keyTopOf(after) - keyTopOf(before) - (after.topMostY - before.topMostY); + + it('패널이 밴드 안에 머무는 동안 트랙 높이를 바꾸면 키는 화면에서 제자리다', () => { + const plugins = [{ x: 260, y: 150, width: 200, height: 210 }]; + const tall = computeLayout(makeInput({ plugins })); + const short = computeLayout({ + ...makeInput({ plugins }), + trackHeight: 200, + }); + expect(keyScreenShift(tall, short)).toBe(0); + }); + + it('밴드 위로 나간 패널을 옮기면 창 원점은 그대로고 fixed-position만 따라간다', () => { + const upper = computeLayout( + makeInput({ plugins: [{ x: 260, y: -200, width: 200, height: 200 }] }), + ); + const lower = computeLayout( + makeInput({ plugins: [{ x: 260, y: -100, width: 200, height: 200 }] }), + ); + // 창 위쪽을 정하는 요소의 이동은 기존처럼 창 원점 보정이 없다 + expect(lower.topMostY).toBe(upper.topMostY); + expect(lower.contentOrigin!.y - upper.contentOrigin!.y).toBe(100); + }); + + it('노트 효과를 끈 채 키 위에 요소가 있으면 키 배치가 달라져도 창 원점 보정이 없다', () => { + // 탭 전환·키 이동처럼 키 상단만 다른 두 배치 + const stats = [{ dx: 260, dy: 100, width: 60, height: 60 }]; + const a = computeLayout({ ...makeInput({ stats }), trackHeight: 0 }); + const moved = makeInput({ stats }); + const b = computeLayout({ + ...moved, + currentPositions: moved.currentPositions.map( + (pos) => ({ ...(pos as object), dy: 200 } as never), + ), + trackHeight: 0, + }); + expect(b.topMostY).toBe(a.topMostY); + }); + + it('보이는 키가 있으면 숨긴 키의 위치는 창에 영향을 주지 않는다', () => { + const base = makeInput({ + stats: [{ dx: 260, dy: 100, width: 60, height: 60 }], + }); + const withHidden = computeLayout({ + ...base, + currentKeys: [...base.currentKeys, 'E'], + currentPositions: [ + ...base.currentPositions, + { + id: 'E', + dx: 0, + dy: -1000, + width: 60, + height: 60, + hidden: true, + } as never, + ], + }); + const without = computeLayout(base); + expect(withHidden.topMostY).toBe(without.topMostY); + expect(withHidden.contentSize).toEqual(without.contentSize); + expect(withHidden.contentOrigin).toEqual(without.contentOrigin); + }); + + const sideExtra = { + stats: [{ dx: 0, dy: 100, width: 60, height: 60 }], + plugins: [{ x: 260, y: 100, width: 200, height: 260 }], + }; + + it('노트 효과를 끈 채 키를 모두 숨겨도 남은 요소는 화면에서 제자리다', () => { + const shown = computeLayout({ ...makeInput(sideExtra), trackHeight: 0 }); + const hidden = computeLayout({ + ...makeInput({ ...sideExtra, hideKeys: true }), + trackHeight: 0, + }); + const statTopOf = (layout: ReturnType) => + (layout.displayStatPositions[0] as { dy: number }).dy; + expect( + statTopOf(hidden) - statTopOf(shown) - (hidden.topMostY - shown.topMostY), + ).toBe(0); + }); + + it('키를 모두 숨기면 키가 없는 배치와 같고 숨긴 키 자리는 창을 넓히지 않는다', () => { + const hidden = makeInput({ ...sideExtra, hideKeys: true }); + const withFarHidden = computeLayout({ + ...hidden, + currentKeys: [...hidden.currentKeys, 'E'], + currentPositions: [ + ...hidden.currentPositions, + { + id: 'E', + dx: 0, + dy: -1000, + width: 60, + height: 60, + hidden: true, + } as never, + ], + }); + const noKeys = computeLayout({ + ...hidden, + currentKeys: [], + currentPositions: [], + }); + expect(withFarHidden.contentSize).toEqual(noKeys.contentSize); + expect(withFarHidden.topMostY).toBe(noKeys.topMostY); + expect(computeLayout(hidden).contentSize).toEqual(noKeys.contentSize); + }); + + it.each([0.1, -0.1, 5])( + '옆 패널이 밴드 안에 있을 때 키를 %s° 돌려도 키는 화면에서 제자리다', + (rotation) => { + const plugins = [{ x: 260, y: 100, width: 200, height: 260 }]; + const flat = computeLayout(makeInput({ plugins })); + const tilted = computeLayout(makeInput({ plugins, rotation })); + // 회전으로 밴드 예약이 꺼져도 트랙은 거의 같은 높이로 뻗는다 + expect(keyScreenShift(flat, tilted)).toBeCloseTo(0, 6); + }, + ); + + it('밴드 밖 패널이 있어도 한 키의 노트 오프셋을 바꾸면 키는 화면에서 제자리다', () => { + const plugins = [{ x: 260, y: -300, width: 200, height: 660 }]; + const before = computeLayout(makeInput({ plugins })); + const after = computeLayout(makeInput({ plugins, noteOffsetYA: -100 })); + expect(keyScreenShift(before, after)).toBe(0); + }); + + it('음수 노트 Y 오프셋은 자기 키의 노트만 그만큼 올린다', () => { + const layout = computeLayout(makeInput({ noteOffsetYA: -100 })); + const [trackA, trackB] = tracksOf(layout); + const keyTop = keyTopOf(layout); + expect(trackA.position.dy).toBe(keyTop - 100); + expect(trackB.position.dy).toBe(keyTop); + // 오프셋 트랙 끝까지만 창이 자란다 + expect(layout.contentSize!.height).toBe(420 + 100); + }); +}); diff --git a/src/renderer/hooks/shared/useLayoutComputation.ts b/src/renderer/hooks/shared/useLayoutComputation.ts index ebd4f0d1..9fd94ea0 100644 --- a/src/renderer/hooks/shared/useLayoutComputation.ts +++ b/src/renderer/hooks/shared/useLayoutComputation.ts @@ -197,6 +197,18 @@ export function computeLayout(input: LayoutInput) { }; })(); + // 자동 보정 히트라인 - 보이는 키 상변 중 가장 위. 노트 오프셋은 키별 트랙에서 + // 따로 얹고, 키 옆 패널·카운터는 밴드와 겹쳐 놓일 수 있으니 시작선을 밀지 않는다. + // 보이는 키가 없으면 콘텐츠 상단 - 숨긴 키 좌표는 창을 넓히지 않는다 + const hitlineY = (() => { + let top: number | null = null; + for (const pos of currentPositions) { + if (!pos || pos.hidden) continue; + top = top === null ? pos.dy : Math.min(top, pos.dy); + } + return top ?? contentBounds?.minY; + })(); + const unionBounds = (a: Bounds, b: Bounds): Bounds => ({ minX: Math.min(a.minX, b.minX), minY: Math.min(a.minY, b.minY), @@ -296,7 +308,7 @@ export function computeLayout(input: LayoutInput) { return { minX, minY, maxX, maxY }; })(); - // 트랙 예약 - 축 정렬 키의 트랙은 콘텐츠 상단 위 밴드 하나로 예약하고, + // 트랙 예약 - 축 정렬 키의 트랙은 히트라인 위 밴드 하나로 예약하고, // 회전 키의 트랙은 자기 상변에서 기울어져 흐르므로 회전 AABB를 창·배경에 합산한다 const trackKeyPositions = currentKeys.map( (_key, index) => currentPositions[index], @@ -310,6 +322,11 @@ export function computeLayout(input: LayoutInput) { const reserveTop = hasUnrotatedTrackKey || visibleTrackKeys.length === 0 ? trackHeight : 0; + // 오버행을 뺀 창 상단 - 밴드 위로 나간 요소만 창을 더 키운다 + const frameTop = + contentBounds && hitlineY !== undefined + ? Math.min(contentBounds.minY, hitlineY - reserveTop) + : 0; // 회전 키의 자동 시작선 보정 - 같은 방향(각도 오차 안)으로 흐르는 키끼리 오프셋 없는 // 상변을 진행축에 투영해 가장 앞선 선에 맞춘다. 회전 0의 "한 줄에서 시작"을 방향별로 // 일반화한 것이고, 혼자면 자기 상변. 사용자 노트 오프셋은 보정 뒤 로컬 프레임으로 @@ -349,11 +366,7 @@ export function computeLayout(input: LayoutInput) { const pos = trackKeyPositions[index]; const onHitline = (pos.rotation ?? 0) === 0 && pos.noteAutoYCorrection !== false; - return trackGeometry( - pos, - false, - onHitline ? contentBounds?.minY : undefined, - ); + return trackGeometry(pos, false, onHitline ? hitlineY : undefined); }); groupSameFlowAngles( candidates.map((index) => trackKeyPositions[index].rotation ?? 0), @@ -383,7 +396,7 @@ export function computeLayout(input: LayoutInput) { const onHitline = (pos.rotation ?? 0) === 0 && pos.noteAutoYCorrection !== false; const { rect } = translateTrackGeometry( - trackGeometry(pos, true, onHitline ? contentBounds?.minY : undefined), + trackGeometry(pos, true, onHitline ? hitlineY : undefined), rotatedFlowShiftByIndex.get(index) ?? 0, ); acc = acc ? unionBounds(acc, rect) : rect; @@ -391,11 +404,41 @@ export function computeLayout(input: LayoutInput) { return acc; })(); + // 창 원점 보정 기준점 - 트랙이 히트라인 위로 뻗는 높이를 밴드로 보고 그 아래 콘텐츠 상단. + // 창 좌표가 패딩 + 밴드 + 오버행이라 노트 길이·오버행 변화만 창 위치 보정으로 이어지고, + // 요소 편집·탭 전환·키 회전은 창 원점을 둔다. 뻗는 높이는 사용자 노트 오프셋을 빼고 + // 재서 키별 오프셋 편집이 기준을 옮기지 않는다. 밴드 밖으로 나간 요소가 있을 때 + // 노트 길이가 바뀌면 그 몫만큼 보정이 남는다 + const contentOrigin = (() => { + if (!contentBounds || hitlineY === undefined) return null; + let baseTrackTop: number | null = null; + if (trackHeight > 0) { + for (let index = 0; index < trackKeyPositions.length; index += 1) { + const pos = trackKeyPositions[index]; + if (!pos || pos.hidden) continue; + const onHitline = + (pos.rotation ?? 0) === 0 && pos.noteAutoYCorrection !== false; + const { rect } = translateTrackGeometry( + trackGeometry(pos, false, onHitline ? hitlineY : undefined), + rotatedFlowShiftByIndex.get(index) ?? 0, + ); + baseTrackTop = + baseTrackTop === null ? rect.minY : Math.min(baseTrackTop, rect.minY); + } + } + const reach = + baseTrackTop === null ? reserveTop : Math.max(0, hitlineY - baseTrackTop); + return { + x: contentBounds.minX, + y: Math.min(contentBounds.minY + reach, hitlineY), + }; + })(); + // 음수 오프셋·키보다 넓은 노트도 회전 여부와 관계없이 실제 트랙 끝까지 예약 - // 창 바운즈 = 콘텐츠·도달 범위 + 위쪽 트랙 밴드 + 트랙 AABB + // 창 바운즈 = 콘텐츠·도달 범위 + 히트라인 위 트랙 밴드 + 트랙 AABB const windowBounds: Bounds | null = (() => { if (!bounds) return null; - const banded = { ...bounds, minY: bounds.minY - reserveTop }; + const banded = { ...bounds, minY: Math.min(bounds.minY, frameTop) }; return trackBounds ? unionBounds(banded, trackBounds) : banded; })(); @@ -416,10 +459,7 @@ export function computeLayout(input: LayoutInput) { // 배경의 화면상 위치·크기는 오버행 유무와 무관하게 동일하다 const backgroundBox = (() => { if (!contentBounds || !windowBounds) return null; - let area: Bounds = { - ...contentBounds, - minY: contentBounds.minY - reserveTop, - }; + let area: Bounds = { ...contentBounds, minY: frameTop }; if (rotatedFaceBounds) area = unionBounds(area, rotatedFaceBounds); if (trackBounds) area = unionBounds(area, trackBounds); return { @@ -462,13 +502,15 @@ export function computeLayout(input: LayoutInput) { ? { x: offsetX, y: offsetY } : { x: 0, y: 0 }; - // 콘텐츠 상단의 창 좌표. 위쪽 오버행이 없으면 트랙 밴드 + PADDING과 같다 - const topMostY = contentBounds ? contentBounds.minY + offsetY : 0; + // 콘텐츠 원점의 창 좌표. 위쪽 오버행이 없으면 트랙 밴드 + PADDING과 같다 + const topMostY = contentOrigin ? contentOrigin.y + offsetY : 0; // 콘텐츠 왼쪽의 창 좌표. 왼쪽 오버행이 없으면 PADDING과 같다 - const leftMostX = contentBounds ? contentBounds.minX + offsetX : 0; + const leftMostX = contentOrigin ? contentOrigin.x + offsetX : 0; + // 히트라인의 창 좌표 + const hitlineWindowY = hitlineY !== undefined ? hitlineY + offsetY : 0; // WebGL 트랙 계산 - 지오메트리는 trackGeometry 단일 정의. - // 회전 0은 콘텐츠 상단(topMostY), 회전 키는 같은 방향 키들의 공통 시작선에 맞춘다 + // 회전 0은 히트라인, 회전 키는 같은 방향 키들의 공통 시작선에 맞춘다 const webglTracks = currentKeys .map((key, index) => { const originalPosition = currentPositions[index]; @@ -490,7 +532,7 @@ export function computeLayout(input: LayoutInput) { noteAlignment: position.noteAlignment, noteOffsetX: position.noteOffsetX, noteOffsetY: position.noteOffsetY, - hitline: useAutoCorrection ? topMostY : undefined, + hitline: useAutoCorrection ? hitlineWindowY : undefined, }), rotatedFlowShiftByIndex.get(index) ?? 0, ); @@ -548,8 +590,9 @@ export function computeLayout(input: LayoutInput) { positionOffset, topMostY, leftMostX, - // fixed-position 델타의 기준점 - 창 바운즈가 아니라 콘텐츠 원점을 쓴다 contentBounds, + // fixed-position 델타의 기준점 - 창 바운즈가 아니라 콘텐츠 원점을 쓴다 + contentOrigin, webglTracks, }; } diff --git a/src/renderer/windows/overlay/App.tsx b/src/renderer/windows/overlay/App.tsx index f5ef336c..76d1b1c1 100644 --- a/src/renderer/windows/overlay/App.tsx +++ b/src/renderer/windows/overlay/App.tsx @@ -216,7 +216,7 @@ export default function App() { ); const { backgroundBox, - contentBounds, + contentOrigin, displayPositions, displayStatPositions, displayGraphPositions, @@ -257,7 +257,7 @@ export default function App() { } | null>(null); useEffect(() => { - if (!contentBounds || !contentSize) return; + if (!contentOrigin || !contentSize) return; // OBS 오버레이에는 네이티브 창이 없다 - allowlist 밖이라 호출이 항상 거부되고, // 기준선을 지우는 실패 처리와 맞물려 레이아웃이 바뀔 때마다 헛호출이 반복된다 if (window.__dmn_runtime === 'obs') return; @@ -270,8 +270,8 @@ export default function App() { const contentLeftOffset = leftMostX; // fixed-position 델타는 콘텐츠 원점 기준 - 창 바운즈 기준이면 오버행 시 // 콘텐츠 오프셋 보정과 겹쳐 이중 이동이 된다 - const currentMinX = contentBounds.minX; - const currentMinY = contentBounds.minY; + const currentMinX = contentOrigin.x; + const currentMinY = contentOrigin.y; // 이전 값과 비교하여 실제로 변경되었을 때만 resize 호출 const lastParams = lastResizeParams.current; @@ -332,7 +332,7 @@ export default function App() { setResizeInFlight((count) => Math.max(0, count - 1)); }); }, [ - contentBounds, + contentOrigin, contentSize, topMostY, leftMostX, From fd5dc53488f105f73276c4836526dc6aafe50df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 19 Oct 2026 02:45:41 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EC=9E=90=EB=8F=99=20=ED=9E=88?= =?UTF-8?q?=ED=8A=B8=EB=9D=BC=EC=9D=B8=20=EB=B3=B4=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=95=88=EB=82=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/api-reference/keys/page.mdx | 10 ++++++---- docs/content/ko/api-reference/keys/page.mdx | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/content/en/api-reference/keys/page.mdx b/docs/content/en/api-reference/keys/page.mdx index a9e9b8ce..b9612c98 100644 --- a/docs/content/en/api-reference/keys/page.mdx +++ b/docs/content/en/api-reference/keys/page.mdx @@ -403,10 +403,12 @@ share the field. The logical box (`dx`/`dy`/`width`/`height`) stays axis aligned while the face, the note track, and the outside counter rotate together about the box center. Omitting the field means 0; `null`, non-finite values, and values outside `-180..180` are rejected. Automatic hitline -correction (`noteAutoYCorrection`) aligns unrotated keys to the content top, -and aligns rotated keys that flow in the same direction (within 0.5°) to the -top edge that is furthest along that direction. A key rotated on its own -starts at its own top edge, and note offsets apply after the correction. +correction (`noteAutoYCorrection`) aligns unrotated keys to the top edge of +the highest visible key, and aligns rotated keys that flow in the same +direction (within 0.5°) to the top edge that is furthest along that +direction. Other elements such as stats, graphs, and plugins do not move the +start line. A key rotated on its own starts at its own top edge, and note +offsets apply to their own key after the correction. Sprites also have an element-level placement `rotation`, independent of their idle and per-pose rotations. See [Sprite Position Types](/docs/api-reference/editor#sprite-position-types). diff --git a/docs/content/ko/api-reference/keys/page.mdx b/docs/content/ko/api-reference/keys/page.mdx index 957b5c58..297c708d 100644 --- a/docs/content/ko/api-reference/keys/page.mdx +++ b/docs/content/ko/api-reference/keys/page.mdx @@ -235,10 +235,11 @@ interface ElementShadowSpec { `dx`/`dy`/`width`/`height` 논리 상자는 축 정렬 그대로 두고 얼굴, 노트 트랙, 외부 카운터가 상자 중심을 축으로 함께 돕니다. 생략하면 0이고 `null`, 비유한값, `-180~180` 밖의 값은 거절됩니다. 자동 히트라인 -보정(`noteAutoYCorrection`)은 회전 0인 키는 콘텐츠 상단에, 회전한 키는 -같은 방향(각도 차 0.5° 이내)으로 도는 키끼리 가장 앞선 상변에 시작선을 -맞춥니다. 혼자 돌린 키는 자기 상변에서 시작하고 노트 오프셋은 그 뒤에 -적용됩니다. 스프라이트도 요소 배치용 `rotation`을 가지며 대기·자세별 회전과 +보정(`noteAutoYCorrection`)은 회전 0인 키는 보이는 키 중 가장 위 키의 +상변에, 회전한 키는 같은 방향(각도 차 0.5° 이내)으로 도는 키끼리 가장 앞선 +상변에 시작선을 맞춥니다. 통계·그래프·플러그인 같은 다른 요소는 시작선을 +옮기지 않습니다. 혼자 돌린 키는 자기 상변에서 시작하고 노트 오프셋은 그 +키에만 보정 뒤에 적용됩니다. 스프라이트도 요소 배치용 `rotation`을 가지며 대기·자세별 회전과 독립적으로 적용합니다. [스프라이트 위치 타입](/docs/api-reference/editor#스프라이트-위치-타입)을 참고하세요. ```javascript