From 153be98780dbf3a85de899894ed137f4f58a5f33 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 2 Sep 2026 10:57:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(materials):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BB?= =?UTF-8?q?=D0=B5=D0=B3=D0=B5=D0=BD=D0=B4=D1=8B=20=D0=B8=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D0=BA=D0=B5=D1=80=D0=BE=D0=B2=20=D0=BD=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B8=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск. Co-authored-by: Cursor --- app/main/index.ts | 6 +- .../project/zipStore.legacyContract.test.ts | 17 +++ app/main/project/zipStore.ts | 12 ++ app/renderer/control/ControlApp.tsx | 90 +++++++------- app/renderer/shared/PresentationView.tsx | 41 ++++--- .../materials/MaterialLegendPanel.module.css | 7 +- .../shared/materials/MaterialLegendPanel.tsx | 13 +- .../materials/MaterialOverlay.module.css | 43 ++++++- .../shared/materials/MaterialOverlay.tsx | 78 +++++++++--- .../playerToken/PlayerTokenView.module.css | 6 +- .../shared/sceneOverlay/SceneOverlayHost.tsx | 88 +++++++++----- .../sceneOverlay/SceneOverlayViewContext.tsx | 8 ++ .../sceneOverlay/sceneOverlayHost.test.ts | 53 ++++++++- .../ui/secondaryWindows.stability.test.ts | 11 ++ app/shared/types/materialLegend.test.ts | 72 +++++++++++ app/shared/types/materialLegend.ts | 112 ++++++++++++++++++ package.json | 2 +- 17 files changed, 531 insertions(+), 128 deletions(-) create mode 100644 app/shared/types/materialLegend.test.ts diff --git a/app/main/index.ts b/app/main/index.ts index f596687..b35ae20 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -745,11 +745,7 @@ async function main() { const open = projectStore.getOpenProject(); if (!open) throw new Error('No open project'); const gn = graphNodeId ? open.sceneGraphNodes.find((n) => n.id === graphNodeId) : null; - await projectStore.updateProject((p) => ({ - ...p, - currentGraphNodeId: graphNodeId, - currentSceneId: gn ? gn.sceneId : null, - })); + projectStore.setSessionPointer(graphNodeId, gn ? gn.sceneId : null); effectsStore.clear(); materialsOverlayStore.clear(); npcsOverlayStore.clear(); diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 272f858..464965b 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -105,6 +105,23 @@ void test('zipStore: listProjects skips unreadable archives', () => { ); }); +void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => { + const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); + const m = src.match(/setSessionPointer\([\s\S]*?\n \}/); + assert.ok(m); + assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/); +}); + +void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => { + const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8'); + const m = src.match( + /registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/, + ); + assert.ok(m); + assert.match(m[0], /setSessionPointer/); + assert.doesNotMatch(m[0], /updateProject/); +}); + void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => { const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8'); const i = src.indexOf('export async function replaceFileAtomic'); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index c38c5e2..99c0556 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -411,6 +411,18 @@ export class ZipProjectStore { return this.openProject?.project ?? null; } + /** Только указатель сессии в памяти — без записи project.json на диск. */ + setSessionPointer(currentGraphNodeId: GraphNodeId | null, currentSceneId: SceneId | null): Project { + const open = this.openProject; + if (!open) throw new Error('No open project'); + open.project = { + ...open.project, + currentGraphNodeId, + currentSceneId, + }; + return open.project; + } + /** Публичный URL для ассетов — кастомная схема `dnd:` (см. `registerDndAssetProtocol`). */ getAssetFileUrl(assetId: AssetId): string | null { if (!this.getAssetReadInfo(assetId)) return null; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index 96f6533..0543d54 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -49,7 +49,7 @@ import type { EffectInstance, EffectToolType, ExplosionInstance } from '../../sh import { SceneDarknessOverlay } from '../shared/effects/SceneDarknessOverlay'; import { useEffectsState } from '../shared/effects/useEffectsState'; import { useSceneDarknessState } from '../shared/effects/useSceneDarknessState'; -import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT } from '../../shared/types'; +import { DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_NPCS_OVERLAY_LAYOUT, defaultLegendLayoutForMaterial } from '../../shared/types'; import { MaterialLegendPanel } from '../shared/materials/MaterialLegendPanel'; import { MaterialOverlay } from '../shared/materials/MaterialOverlay'; import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverlayState'; @@ -2549,12 +2549,7 @@ export function ControlApp() { material, layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT, legendLayout: - materialsOverlay?.legendLayouts[id] ?? { - ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, - cx: 0.82, - cy: 0.5, - scale: 0.85, - }, + materialsOverlay?.legendLayouts[id] ?? defaultLegendLayoutForMaterial(), }; }) .filter((x): x is NonNullable => x !== null) @@ -2577,6 +2572,7 @@ export function ControlApp() { const showMaterial = materialItems.length > 0; const showNpcs = npcItems.length > 0; const screenRect = presentationScreenRect; + const overlayViewport = screenRect; const showGuide = Boolean(screenRect); if (!showMaterial && !showNpcs && !showGuide) return null; const closes = [ @@ -2614,7 +2610,7 @@ export function ControlApp() { <> + {materialItems.map(({ material, legendLayout }) => + material.legend?.enabled ? ( + { + void materialsApi.dispatch({ + kind: 'legendLayout.set', + materialId: material.id, + layout: nextLayout, + }); + }} + /> + ) : null, + )} + + } > - {materialItems.map(({ material, layout, legendLayout }) => ( - - { - void materialsApi.dispatch({ - kind: 'layout.set', - materialId: material.id, - layout: nextLayout, - }); - }} - {...(material.legend?.enabled - ? { legendMarkers: material.legend.markers ?? [] } - : {})} - /> - {material.legend?.enabled ? ( - { - void materialsApi.dispatch({ - kind: 'legendLayout.set', - materialId: material.id, - layout: nextLayout, - }); - }} - /> - ) : null} - + {materialItems.map(({ material, layout }) => ( + { + void materialsApi.dispatch({ + kind: 'layout.set', + materialId: material.id, + layout: nextLayout, + }); + }} + {...(material.legend?.enabled && (material.legend.markers?.length ?? 0) > 0 + ? { legendMarkers: material.legend.markers } + : {})} + /> ))} {showNpcs ? ( => x !== null) @@ -285,20 +285,29 @@ export function PresentationView({ contentRect ? ( ) : null} - 0 || activeNpcItems.length > 0}> - {activeMaterialItems.map(({ material, layout, legendLayout }) => ( - - - {material.legend?.enabled ? ( - - ) : null} - + 0 || activeNpcItems.length > 0} + legendLayer={ + <> + {activeMaterialItems.map(({ material, legendLayout }) => + material.legend?.enabled ? ( + + ) : null, + )} + + } + > + {activeMaterialItems.map(({ material, layout }) => ( + 0 + ? { legendMarkers: material.legend.markers } + : {})} + /> ))} {activeNpcItems.length > 0 ? : null} diff --git a/app/renderer/shared/materials/MaterialLegendPanel.module.css b/app/renderer/shared/materials/MaterialLegendPanel.module.css index dcea5e9..816848d 100644 --- a/app/renderer/shared/materials/MaterialLegendPanel.module.css +++ b/app/renderer/shared/materials/MaterialLegendPanel.module.css @@ -1,10 +1,8 @@ .panel { position: absolute; z-index: 45; - min-width: 200px; - max-width: min(360px, 42vw); - max-height: min(70vh, 520px); - overflow: auto; + overflow: visible; + box-sizing: border-box; padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.14); @@ -16,6 +14,7 @@ touch-action: none; user-select: none; pointer-events: auto; + transform-origin: center center; } .title { diff --git a/app/renderer/shared/materials/MaterialLegendPanel.tsx b/app/renderer/shared/materials/MaterialLegendPanel.tsx index d6125de..5a0883d 100644 --- a/app/renderer/shared/materials/MaterialLegendPanel.tsx +++ b/app/renderer/shared/materials/MaterialLegendPanel.tsx @@ -2,6 +2,7 @@ import React, { useRef } from 'react'; import type { MaterialLegend, MaterialsOverlayLayout } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; +import { legendPanelVisualLayout } from '../../../shared/types/materialLegend'; import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; import styles from './MaterialLegendPanel.module.css'; @@ -21,20 +22,24 @@ export function MaterialLegendPanel({ }: Props) { const host = useSceneOverlayView(); const view = host?.view ?? { w: 1, h: 1 }; + const viewport = host?.viewport ?? { x: 0, y: 0, w: view.w, h: view.h }; const dragRef = useRef<{ startX: number; startY: number; origin: MaterialsOverlayLayout } | null>( null, ); const effective = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT; - const w = Math.max(180, Math.min(view.w * 0.36, 340) * effective.scale); - const left = effective.cx * view.w - w / 2; - const top = effective.cy * view.h - 40; + const vis = legendPanelVisualLayout(effective, viewport); if (!legend.enabled || legend.items.length === 0) return null; return (
{ if (!editable || !onLayoutChange) return; e.stopPropagation(); diff --git a/app/renderer/shared/materials/MaterialOverlay.module.css b/app/renderer/shared/materials/MaterialOverlay.module.css index dede6d2..fc2088a 100644 --- a/app/renderer/shared/materials/MaterialOverlay.module.css +++ b/app/renderer/shared/materials/MaterialOverlay.module.css @@ -17,6 +17,29 @@ pointer-events: none; } +.hostViewport { + position: absolute; + overflow: hidden; + pointer-events: none; + z-index: 41; +} + +.markerLayer { + position: absolute; + inset: 0; + z-index: 45; + overflow: visible; + pointer-events: none; +} + +.legendLayer { + position: absolute; + inset: 0; + z-index: 46; + overflow: visible; + pointer-events: none; +} + .viewportGuide { position: absolute; inset: 0; @@ -65,7 +88,7 @@ width: 100%; height: 100%; display: block; - object-fit: fill; + object-fit: contain; border-radius: 6px; box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55); user-select: none; @@ -90,6 +113,24 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); } +.legendMarkerHost { + position: absolute; + transform-origin: center center; + width: 26px; + height: 26px; + border-radius: 50%; + background: #1e3a5f; + border: 2px solid #f5c542; + color: #fff; + font-size: 12px; + font-weight: 800; + display: grid; + place-items: center; + pointer-events: none; + z-index: 47; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); +} + .handle { position: absolute; width: 12px; diff --git a/app/renderer/shared/materials/MaterialOverlay.tsx b/app/renderer/shared/materials/MaterialOverlay.tsx index df4f87a..2a0df21 100644 --- a/app/renderer/shared/materials/MaterialOverlay.tsx +++ b/app/renderer/shared/materials/MaterialOverlay.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; +import { + LEGEND_MARKER_DESIGN_SIZE, + legendMarkerPositionInOverlayHost, + legendMarkerScreenScale, +} from '../../../shared/types/materialLegend'; import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; import { useAssetUrl } from '../useAssetImageUrl'; @@ -111,6 +117,7 @@ export function MaterialOverlay({ const rootRef = embedded && host ? host.rootRef : localRootRef; const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 1600, h: 900 }); const [localView, setLocalView] = useState({ w: 1, h: 1 }); + const [markerLayerReady, setMarkerLayerReady] = useState(false); /** Локальный layout + IPC не чаще 1/frame (лайв, без спама pointermove). */ const [draftLayout, setDraftLayout] = useState(null); const pendingLayoutRef = useRef(null); @@ -170,6 +177,22 @@ export function MaterialOverlay({ pendingLayoutRef.current = null; }, [layout]); + useEffect(() => { + if (!url) return; + const img = new Image(); + const apply = () => { + setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); + }; + img.onload = apply; + img.src = url; + if (img.complete) apply(); + }, [url]); + + useLayoutEffect(() => { + if (!embedded) return; + setMarkerLayerReady(Boolean(host?.markerLayerRef.current)); + }, [embedded, host, host?.view.w, host?.view.h, natural.w, natural.h, legendMarkers, layout, draftLayout]); + if (!assetId || !url) return null; const interactive = editable || showClose || Boolean(zoomTool); @@ -392,18 +415,6 @@ export function MaterialOverlay({ setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); }} /> - {(legendMarkers ?? []).map((m) => ( -
- {m.number} -
- ))} {editable && !zoomTool ? (['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( - ))} -
- ) : null} +
+
+ {showViewportGuide ?
: null} + {children} + {closes.length > 0 ? ( +
+ {closes.map((c) => ( + + ))} +
+ ) : null} +
+ {legendLayer ?
{legendLayer}
: null}
); diff --git a/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx b/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx index 18395c6..9acd78f 100644 --- a/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx +++ b/app/renderer/shared/sceneOverlay/SceneOverlayViewContext.tsx @@ -1,8 +1,16 @@ import React, { createContext, useContext } from 'react'; +import type { SceneOverlayViewport } from './overlayViewport'; + export type SceneOverlayViewContextValue = { + /** Внешний контейнер оверлея (полная область превью / презентации). */ rootRef: React.RefObject; + /** Слой маркеров легенды на материале (вне clipped viewport). */ + markerLayerRef: React.RefObject; + /** Размеры области раскладки материалов (viewport.w/h). */ view: { w: number; h: number }; + /** Смещение и размер viewport внутри rootRef (contentRect на презентации). */ + viewport: SceneOverlayViewport | null; }; export const SceneOverlayViewContext = createContext(null); diff --git a/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts b/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts index d377972..2a52aa2 100644 --- a/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts +++ b/app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts @@ -46,7 +46,7 @@ void test('MaterialOverlay / NpcsSceneOverlay поддерживают embedded assert.ok(npcs.includes('embedded')); assert.ok(npcs.includes('useSceneOverlayView')); // В embedded-ветке не рисуем второй dim. - assert.match(material, /if \(embedded\) \{\s*return frame;/); + assert.match(material, /if \(embedded\) \{[\s\S]*createPortal/); assert.match(npcs, /if \(embedded\) \{\s*return <>\{frames\}<\/>;/); }); @@ -76,8 +76,53 @@ void test('overlays: layout IPC live через rAF coalesce, RO host coalesced' npcs, /requestAnimationFrame\(\(\) => \{[\s\S]*?onLayoutChangeRef\.current\?\.\(item\.npcId, pending\)/, ); - assert.match( - npcs, - /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/, + assert.match(npcs, /if \(drag\.mode === 'move'\) \{[\s\S]*?publishDraft\(\{[\s\S]*?return;\s*\}/, ); }); + +void test('SceneOverlayHost: presentation and control share player-screen viewport for overlays', () => { + const presentation = fs.readFileSync(path.join(root, 'app/renderer/shared/PresentationView.tsx'), 'utf8'); + const control = fs.readFileSync(path.join(root, 'app/renderer/control/ControlApp.tsx'), 'utf8'); + const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8'); + assert.ok(presentation.includes(' { + const panel = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.tsx'), + 'utf8', + ); + const css = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialLegendPanel.module.css'), + 'utf8', + ); + assert.ok(panel.includes('legendPanelVisualLayout')); + assert.match(css, /overflow:\s*visible/); + assert.doesNotMatch(css, /overflow:\s*auto/); + assert.doesNotMatch(css, /max-height/); +}); + +void test('MaterialOverlay: legend markers use viewport positioning', () => { + const material = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialOverlay.tsx'), + 'utf8', + ); + const css = fs.readFileSync( + path.join(root, 'app/renderer/shared/materials/MaterialOverlay.module.css'), + 'utf8', + ); + const host = fs.readFileSync(path.join(here, 'SceneOverlayHost.tsx'), 'utf8'); + assert.ok(material.includes('legendMarkerPositionInOverlayHost')); + assert.ok(material.includes('legendMarkerScreenScale')); + assert.ok(material.includes('createPortal')); + assert.ok(material.includes('markerLayerRef')); + assert.match(css, /object-fit:\s*contain/); + assert.ok(host.includes('legendLayer')); + assert.ok(host.includes('markerLayer')); + assert.ok(host.includes('hostViewport')); +}); diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index 41a2d28..cb1f809 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -125,6 +125,17 @@ void test('EditorApp: split Run button and launch-with-players modal', () => { assert.ok(editor.includes('top.runWithPlayers')); }); +void test('PlayerTokenView: name plate shows full text without ellipsis', () => { + const css = fs.readFileSync( + path.join(rendererRoot, 'shared/playerToken/PlayerTokenView.module.css'), + 'utf8', + ); + const block = css.match(/\.namePlate\s*\{[^}]+\}/)?.[0] ?? ''; + assert.match(block, /width:\s*max-content/); + assert.doesNotMatch(block, /text-overflow:\s*ellipsis/); + assert.doesNotMatch(block, /overflow:\s*hidden/); +}); + void test('NpcsEditorApp: token appearance via PlayerTokenView', () => { const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); assert.ok(src.includes('PlayerTokenView')); diff --git a/app/shared/types/materialLegend.test.ts b/app/shared/types/materialLegend.test.ts new file mode 100644 index 0000000..4727673 --- /dev/null +++ b/app/shared/types/materialLegend.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LEGEND_PANEL_DESIGN_WIDTH, + LEGEND_PANEL_REF_VIEW_W, + legendMarkerPositionInContainFrame, + legendMarkerPositionInOverlayHost, + legendMarkerPositionInViewport, + legendMarkerScreenScale, + legendPanelVisualLayout, +} from './materialLegend'; + +void test('legendMarkerPositionInContainFrame: letterboxed 16:9 in square frame', () => { + const p = legendMarkerPositionInContainFrame(0.5, 0.5, 400, 400, 1920, 1080); + assert.ok(Math.abs(p.left - 200) < 0.01); + assert.ok(Math.abs(p.top - 200) < 0.01); +}); + +void test('legendMarkerPositionInContainFrame: top-left UV at image corner', () => { + const p = legendMarkerPositionInContainFrame(0, 0, 400, 400, 1920, 1080); + const imageH = (400 * 1080) / 1920; + const imageY = (400 - imageH) / 2; + assert.ok(Math.abs(p.left - 0) < 0.01); + assert.ok(Math.abs(p.top - imageY) < 0.01); +}); + +void test('legendMarkerPositionInViewport: center marker at frame center', () => { + const p = legendMarkerPositionInViewport(0.5, 0.5, { cx: 0.5, cy: 0.5, rotationDeg: 0 }, 400, 300, 800, 600, 800, 600); + assert.ok(Math.abs(p.left - 400) < 0.01); + assert.ok(Math.abs(p.top - 300) < 0.01); +}); + +void test('legendMarkerPositionInOverlayHost: adds viewport offset', () => { + const p = legendMarkerPositionInOverlayHost( + 0.5, + 0.5, + { cx: 0.5, cy: 0.5, rotationDeg: 0 }, + 400, + 300, + 800, + 600, + { x: 100, y: 50, w: 800, h: 600 }, + ); + assert.ok(Math.abs(p.left - 500) < 0.01); + assert.ok(Math.abs(p.top - 350) < 0.01); +}); + +void test('legendPanelVisualLayout: same cx/cy stays proportional across screen sizes', () => { + const layout = { cx: 0.82, cy: 0.5, scale: 0.85 }; + const a = legendPanelVisualLayout(layout, { x: 10, y: 20, w: 800, h: 450 }); + const b = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 }); + assert.equal(a.width, LEGEND_PANEL_DESIGN_WIDTH); + assert.equal(b.width, LEGEND_PANEL_DESIGN_WIDTH); + assert.ok(Math.abs(a.left - (10 + 0.82 * 800)) < 0.01); + assert.ok(Math.abs(b.left - 0.82 * 1920) < 0.01); + assert.ok(Math.abs(a.scale / b.scale - 800 / LEGEND_PANEL_REF_VIEW_W) < 1e-9); +}); + +void test('legendPanelVisualLayout: size ignores viewport origin (no edge shrink)', () => { + const layout = { cx: 0.02, cy: 0.98, scale: 1 }; + const near = legendPanelVisualLayout(layout, { x: 0, y: 0, w: 1920, h: 1080 }); + const far = legendPanelVisualLayout({ cx: 0.5, cy: 0.5, scale: 1 }, { x: 0, y: 0, w: 1920, h: 1080 }); + assert.equal(near.scale, far.scale); + assert.equal(near.width, far.width); +}); + +void test('legendMarkerScreenScale: proportional to player screen, ignores edge', () => { + assert.equal(legendMarkerScreenScale(1920), 1); + assert.ok(Math.abs(legendMarkerScreenScale(800) - 800 / 1920) < 1e-9); + assert.equal(legendMarkerScreenScale(800), legendMarkerScreenScale(800)); +}); diff --git a/app/shared/types/materialLegend.ts b/app/shared/types/materialLegend.ts index aeaa53e..7f8ae36 100644 --- a/app/shared/types/materialLegend.ts +++ b/app/shared/types/materialLegend.ts @@ -151,3 +151,115 @@ export function legendImageUvToHostPoint( y: contentRect.y + contentRect.h / 2 + (lx * sin + ly * cos), }; } + +/** UV маркера → px внутри кадра MaterialOverlay (object-fit: contain). */ +export function legendMarkerPositionInContainFrame( + nx: number, + ny: number, + frameW: number, + frameH: number, + imageNaturalW: number, + imageNaturalH: number, +): { left: number; top: number } { + const bw = Math.max(1, frameW); + const bh = Math.max(1, frameH); + const aw = Math.max(1, imageNaturalW); + const ah = Math.max(1, imageNaturalH); + const boxAspect = bw / bh; + const targetAspect = aw / ah; + let ir: ContentRect; + if (boxAspect > targetAspect) { + const h = bh; + const w = h * targetAspect; + ir = { x: (bw - w) / 2, y: 0, w, h }; + } else { + const w = bw; + const h = w / targetAspect; + ir = { x: 0, y: (bh - h) / 2, w, h }; + } + return { + left: ir.x + clampLegendCoord(nx) * ir.w, + top: ir.y + clampLegendCoord(ny) * ir.h, + }; +} + +/** UV маркера → px внутри viewport SceneOverlayHost (без смещения viewport.x/y). */ +export function legendMarkerPositionInViewport( + nx: number, + ny: number, + layout: { cx: number; cy: number; rotationDeg: number }, + frameW: number, + frameH: number, + imageNaturalW: number, + imageNaturalH: number, + viewportW: number, + viewportH: number, +): { left: number; top: number } { + const local = legendMarkerPositionInContainFrame(nx, ny, frameW, frameH, imageNaturalW, imageNaturalH); + const cx = layout.cx * viewportW; + const cy = layout.cy * viewportH; + const lx = local.left - frameW / 2; + const ly = local.top - frameH / 2; + const rad = (layout.rotationDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { + left: cx + lx * cos - ly * sin, + top: cy + lx * sin + ly * cos, + }; +} + +/** UV маркера → px в координатах SceneOverlayHost (учитывает viewport offset и поворот кадра). */ +export function legendMarkerPositionInOverlayHost( + nx: number, + ny: number, + layout: { cx: number; cy: number; rotationDeg: number }, + frameW: number, + frameH: number, + imageNaturalW: number, + imageNaturalH: number, + viewport: ContentRect, +): { left: number; top: number } { + const p = legendMarkerPositionInViewport( + nx, + ny, + layout, + frameW, + frameH, + imageNaturalW, + imageNaturalH, + viewport.w, + viewport.h, + ); + return { left: viewport.x + p.left, top: viewport.y + p.top }; +} + +/** Ширина карточки легенды в design-px при scale=1 на экране 1920px. */ +export const LEGEND_PANEL_DESIGN_WIDTH = 320; +export const LEGEND_PANEL_REF_VIEW_W = 1920; +/** Диаметр маркера на материале в design-px (как кружок в списке легенды). */ +export const LEGEND_MARKER_DESIGN_SIZE = 26; + +/** + * Позиция и единый масштаб карточки легенды. + * Размер не зависит от близости к краю — только от ширины экрана игроков и layout.scale. + */ +export function legendPanelVisualLayout( + layout: { cx: number; cy: number; scale: number }, + viewport: ContentRect, +): { left: number; top: number; width: number; scale: number } { + const vw = Math.max(1, viewport.w); + const vh = Math.max(1, viewport.h); + const layoutScale = Number.isFinite(layout.scale) && layout.scale > 0 ? layout.scale : 1; + return { + left: viewport.x + layout.cx * vw, + top: viewport.y + layout.cy * vh, + width: LEGEND_PANEL_DESIGN_WIDTH, + scale: (vw / LEGEND_PANEL_REF_VIEW_W) * layoutScale, + }; +} + +/** Единый масштаб цифр на материале — как у карточки легенды, без сжатия у края. */ +export function legendMarkerScreenScale(viewportW: number): number { + return Math.max(1, viewportW) / LEGEND_PANEL_REF_VIEW_W; +} diff --git a/package.json b/package.json index cda2cc6..4df78f3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build:obfuscate": "node scripts/build.mjs --production --obfuscate", "lint": "eslint . --max-warnings 0", "typecheck": "tsc -p tsconfig.eslint.json --noEmit", - "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", + "test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/renderer/shared/videoSceneMapParity.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts app/main/license/machineFingerprint.test.ts app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/shared/types/sceneDarkness.test.ts app/shared/types/materialLegend.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs scripts/release-native-prep.test.mjs scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs", "format": "prettier . --check", "format:write": "prettier . --write", "postinstall": "patch-package",