feat(materials): free rotate session overlays and show save progress

Match NPC drag-to-rotate on the material frame during playback, and change the add/edit Save button to Saving… while import runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-21 14:56:42 +08:00
parent 0cab7ce7ca
commit c1c332364c
8 changed files with 238 additions and 67 deletions
+13 -3
View File
@@ -18,6 +18,13 @@ function emptyState(): MaterialsOverlayState {
}; };
} }
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
return clampMaterialsLayout({
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
rotationDeg: rotationDeg ?? 0,
});
}
export class MaterialsOverlayStore { export class MaterialsOverlayStore {
private state: MaterialsOverlayState = emptyState(); private state: MaterialsOverlayState = emptyState();
@@ -46,7 +53,7 @@ export class MaterialsOverlayStore {
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeMaterialId: event.materialId, activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, layout: initialLayout(event.rotationDeg),
zoomTool: this.state.zoomTool, zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
@@ -57,7 +64,7 @@ export class MaterialsOverlayStore {
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeMaterialId: event.materialId, activeMaterialId: event.materialId,
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, layout: initialLayout(event.rotationDeg),
zoomTool: this.state.zoomTool, zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
@@ -67,7 +74,10 @@ export class MaterialsOverlayStore {
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
layout: clampMaterialsLayout(event.layout), layout: clampMaterialsLayout({
...this.state.layout,
...event.layout,
}),
}; };
return this.state; return this.state;
} }
+1 -1
View File
@@ -1593,12 +1593,12 @@ export function ControlApp() {
return ( return (
<MaterialOverlay <MaterialOverlay
assetId={activeMaterial.assetId} assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
editable editable
zoomTool={materialsOverlay?.zoomTool ?? null} zoomTool={materialsOverlay?.zoomTool ?? null}
showClose showClose
closeLabel={t('materials.closeOverlay')} closeLabel={t('materials.closeOverlay')}
rotateLabel={t('materials.rotateOverlay')}
onClose={() => { onClose={() => {
void materialsApi.dispatch({ kind: 'hide' }); void materialsApi.dispatch({ kind: 'hide' });
}} }}
+24 -6
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal, flushSync } from 'react-dom';
import type { MaterialId, ProjectMaterial } from '../../shared/types'; import type { MaterialId, ProjectMaterial } from '../../shared/types';
import { Button, Input } from '../shared/ui/controls'; import { Button, Input } from '../shared/ui/controls';
@@ -63,11 +63,11 @@ export function MaterialEditModal({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape' && !saving) onClose();
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]); }, [onClose, open, saving]);
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => { const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
setFilePath(path); setFilePath(path);
@@ -102,13 +102,28 @@ export function MaterialEditModal({
return createPortal( return createPortal(
<> <>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalBackdrop} /> <button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!saving) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}> <div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}> <div className={styles.modalHeader}>
<div className={styles.modalTitle}> <div className={styles.modalTitle}>
{initial ? t('materials.editTitle') : t('materials.addTitle')} {initial ? t('materials.editTitle') : t('materials.addTitle')}
</div> </div>
<button type="button" aria-label={t('common.close')} onClick={onClose} className={styles.modalClose}> <button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!saving) onClose();
}}
className={styles.modalClose}
disabled={saving}
>
× ×
</button> </button>
</div> </div>
@@ -151,6 +166,7 @@ export function MaterialEditModal({
<div className={styles.muted}>{t('materials.imageEmpty')}</div> <div className={styles.muted}>{t('materials.imageEmpty')}</div>
)} )}
<Button <Button
disabled={saving}
onClick={() => { onClick={() => {
void (async () => { void (async () => {
const picked = await onPickImage(); const picked = await onPickImage();
@@ -177,8 +193,10 @@ export function MaterialEditModal({
onClick={() => { onClick={() => {
if (!canSave) return; if (!canSave) return;
void (async () => { void (async () => {
flushSync(() => {
setSaving(true); setSaving(true);
setError(null); setError(null);
});
try { try {
await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed }); await onSave(filePath ? { name: trimmed, filePath } : { name: trimmed });
onClose(); onClose();
@@ -190,7 +208,7 @@ export function MaterialEditModal({
})(); })();
}} }}
> >
{t('common.save')} {saving ? t('common.saving') : t('common.save')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -54,6 +54,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'common.close': 'Закрыть', 'common.close': 'Закрыть',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.save': 'Сохранить', 'common.save': 'Сохранить',
'common.saving': 'Сохранение…',
'common.edit': 'Редактировать', 'common.edit': 'Редактировать',
'common.understood': 'Понятно', 'common.understood': 'Понятно',
'common.message': 'Сообщение', 'common.message': 'Сообщение',
@@ -376,6 +377,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.tileMenu': 'Меню материала', 'materials.tileMenu': 'Меню материала',
'materials.windowEmpty': 'Добавьте материалы в редакторе.', 'materials.windowEmpty': 'Добавьте материалы в редакторе.',
'materials.closeOverlay': 'Закрыть материал', 'materials.closeOverlay': 'Закрыть материал',
'materials.rotateOverlay': 'Повернуть',
'materials.deleteTitle': 'Удаление материала', 'materials.deleteTitle': 'Удаление материала',
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?', 'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
'materials.zoomIn': 'Увеличить', 'materials.zoomIn': 'Увеличить',
@@ -591,6 +593,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'common.close': 'Close', 'common.close': 'Close',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.save': 'Save', 'common.save': 'Save',
'common.saving': 'Saving…',
'common.edit': 'Edit', 'common.edit': 'Edit',
'common.understood': 'OK', 'common.understood': 'OK',
'common.message': 'Message', 'common.message': 'Message',
@@ -913,6 +916,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.tileMenu': 'Material menu', 'materials.tileMenu': 'Material menu',
'materials.windowEmpty': 'Add materials in the editor.', 'materials.windowEmpty': 'Add materials in the editor.',
'materials.closeOverlay': 'Close material', 'materials.closeOverlay': 'Close material',
'materials.rotateOverlay': 'Rotate',
'materials.deleteTitle': 'Delete material', 'materials.deleteTitle': 'Delete material',
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?', 'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
'materials.zoomIn': 'Zoom in', 'materials.zoomIn': 'Zoom in',
+6 -1
View File
@@ -90,7 +90,12 @@ export function MaterialsApp() {
onSelect={onSelect} onSelect={onSelect}
activeMaterialId={activeId} activeMaterialId={activeId}
onTileActivate={(id) => { onTileActivate={(id) => {
void overlayApi.dispatch({ kind: 'toggle', materialId: id }); const mat = materials.find((m) => m.id === id);
void overlayApi.dispatch({
kind: 'toggle',
materialId: id,
rotationDeg: mat?.rotationDeg ?? 0,
});
}} }}
toolbar={ toolbar={
<> <>
-1
View File
@@ -167,7 +167,6 @@ export function PresentationView({
{activeMaterial ? ( {activeMaterial ? (
<MaterialOverlay <MaterialOverlay
assetId={activeMaterial.assetId} assetId={activeMaterial.assetId}
rotationDeg={activeMaterial.rotationDeg ?? 0}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/> />
) : null} ) : null}
+168 -44
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
import styles from './MaterialOverlay.module.css'; import styles from './MaterialOverlay.module.css';
@@ -9,43 +10,89 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = { type MaterialOverlayProps = {
assetId: AssetId | null; assetId: AssetId | null;
rotationDeg?: 0 | 90 | 180 | 270;
layout: MaterialsOverlayLayout; layout: MaterialsOverlayLayout;
editable?: boolean; editable?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool; zoomTool?: MaterialsZoomTool | NpcsZoomTool;
showClose?: boolean; showClose?: boolean;
onClose?: () => void; onClose?: () => void;
closeLabel?: string; closeLabel?: string;
rotateLabel?: string;
onLayoutChange?: (layout: MaterialsOverlayLayout) => void; onLayoutChange?: (layout: MaterialsOverlayLayout) => void;
onZoomAt?: (nx: number, ny: number) => void; onZoomAt?: (nx: number, ny: number) => void;
}; };
function RotateIcon() {
return (
<svg className={styles.frameRotateSvg} viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path
d="M20 12a8 8 0 1 1-2.2-5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
<path d="M20 4v5h-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
function nextBaseSize( function nextBaseSize(
viewW: number, viewW: number,
viewH: number, viewH: number,
naturalW: number, naturalW: number,
naturalH: number, naturalH: number,
rotationDeg: number,
): { w: number; h: number } { ): { w: number; h: number } {
const swapped = rotationDeg === 90 || rotationDeg === 270; if (naturalW <= 0 || naturalH <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
const iw = swapped ? naturalH : naturalW;
const ih = swapped ? naturalW : naturalH;
if (iw <= 0 || ih <= 0 || viewW <= 0 || viewH <= 0) return { w: 200, h: 120 };
const maxW = viewW * 0.92; const maxW = viewW * 0.92;
const maxH = viewH * 0.88; const maxH = viewH * 0.88;
const fit = Math.min(maxW / iw, maxH / ih); const fit = Math.min(maxW / naturalW, maxH / naturalH);
return { w: iw * fit, h: ih * fit }; return { w: naturalW * fit, h: naturalH * fit };
}
function pointerAngleDeg(centerX: number, centerY: number, clientX: number, clientY: number): number {
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
}
function shortestAngleDelta(fromDeg: number, toDeg: number): number {
let d = toDeg - fromDeg;
while (d > 180) d -= 360;
while (d < -180) d += 360;
return d;
}
function screenToLocal(
clientX: number,
clientY: number,
centerX: number,
centerY: number,
rotationDeg: number,
): { x: number; y: number } {
const rad = (-rotationDeg * Math.PI) / 180;
const dx = clientX - centerX;
const dy = clientY - centerY;
return {
x: dx * Math.cos(rad) - dy * Math.sin(rad),
y: dx * Math.sin(rad) + dy * Math.cos(rad),
};
}
function localToScreenOffset(localX: number, localY: number, rotationDeg: number): { x: number; y: number } {
const rad = (rotationDeg * Math.PI) / 180;
return {
x: localX * Math.cos(rad) - localY * Math.sin(rad),
y: localX * Math.sin(rad) + localY * Math.cos(rad),
};
} }
export function MaterialOverlay({ export function MaterialOverlay({
assetId, assetId,
rotationDeg = 0,
layout, layout,
editable = false, editable = false,
zoomTool = null, zoomTool = null,
showClose = false, showClose = false,
onClose, onClose,
closeLabel = 'Close', closeLabel = 'Close',
rotateLabel = 'Rotate',
onLayoutChange, onLayoutChange,
onZoomAt, onZoomAt,
}: MaterialOverlayProps) { }: MaterialOverlayProps) {
@@ -58,13 +105,20 @@ export function MaterialOverlay({
| { | {
mode: 'resize'; mode: 'resize';
corner: Corner; corner: Corner;
startX: number;
startY: number;
origin: MaterialsOverlayLayout; origin: MaterialsOverlayLayout;
baseW: number; baseW: number;
baseH: number; baseH: number;
viewW: number; viewW: number;
viewH: number; viewH: number;
centerClientX: number;
centerClientY: number;
}
| {
mode: 'rotate';
origin: MaterialsOverlayLayout;
startPointerAngle: number;
centerX: number;
centerY: number;
} }
| null | null
>(null); >(null);
@@ -85,15 +139,13 @@ export function MaterialOverlay({
const zoomCursor = const zoomCursor =
zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : ''; zoomTool === 'zoomIn' ? styles.cursorZoomIn : zoomTool === 'zoomOut' ? styles.cursorZoomOut : '';
const base = nextBaseSize(view.w, view.h, natural.w, natural.h, rotationDeg); const effectiveLayout = layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT;
const w = base.w * layout.scale; const rotationDeg = effectiveLayout.rotationDeg ?? 0;
const h = base.h * layout.scale; const base = nextBaseSize(view.w, view.h, natural.w, natural.h);
const left = layout.cx * view.w - w / 2; const w = base.w * effectiveLayout.scale;
const top = layout.cy * view.h - h / 2; const h = base.h * effectiveLayout.scale;
// Рамка — AABB; до CSS-rotate картинка имеет «натуральную» ориентацию. const left = effectiveLayout.cx * view.w - w / 2;
const swapped = rotationDeg === 90 || rotationDeg === 270; const top = effectiveLayout.cy * view.h - h / 2;
const contentW = swapped ? h : w;
const contentH = swapped ? w : h;
const toNorm = (clientX: number, clientY: number) => { const toNorm = (clientX: number, clientY: number) => {
const root = rootRef.current; const root = rootRef.current;
@@ -105,16 +157,37 @@ export function MaterialOverlay({
}; };
}; };
const layoutCenterClient = () => {
const root = rootRef.current;
if (!root) return { x: 0, y: 0 };
const r = root.getBoundingClientRect();
return {
x: r.left + effectiveLayout.cx * r.width,
y: r.top + effectiveLayout.cy * r.height,
};
};
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const drag = dragRef.current; const drag = dragRef.current;
if (!drag || !onLayoutChange) return; if (!drag || !onLayoutChange) return;
if (drag.mode === 'rotate') {
const angle = pointerAngleDeg(drag.centerX, drag.centerY, e.clientX, e.clientY);
const delta = shortestAngleDelta(drag.startPointerAngle, angle);
onLayoutChange({
...drag.origin,
rotationDeg: drag.origin.rotationDeg + delta,
});
return;
}
const root = rootRef.current; const root = rootRef.current;
if (!root) return; if (!root) return;
const r = root.getBoundingClientRect(); const r = root.getBoundingClientRect();
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
if (drag.mode === 'move') { if (drag.mode === 'move') {
const dx = (e.clientX - drag.startX) / Math.max(1, r.width);
const dy = (e.clientY - drag.startY) / Math.max(1, r.height);
onLayoutChange({ onLayoutChange({
...drag.origin, ...drag.origin,
cx: drag.origin.cx + dx, cx: drag.origin.cx + dx,
@@ -123,13 +196,13 @@ export function MaterialOverlay({
return; return;
} }
const { baseW, baseH, viewW, viewH, origin, corner } = drag; const { baseW, baseH, viewW, viewH, origin, corner, centerClientX, centerClientY } = drag;
const originW = baseW * origin.scale; const originW = baseW * origin.scale;
const originH = baseH * origin.scale; const originH = baseH * origin.scale;
const originLeft = origin.cx * viewW - originW / 2; const originLeft = -originW / 2;
const originTop = origin.cy * viewH - originH / 2; const originTop = -originH / 2;
const originRight = originLeft + originW; const originRight = originW / 2;
const originBottom = originTop + originH; const originBottom = originH / 2;
let anchorX = originLeft; let anchorX = originLeft;
let anchorY = originTop; let anchorY = originTop;
@@ -144,10 +217,15 @@ export function MaterialOverlay({
anchorY = originTop; anchorY = originTop;
} }
const pointerX = e.clientX - r.left; const local = screenToLocal(
const pointerY = e.clientY - r.top; e.clientX,
const newW = Math.max(8, Math.abs(pointerX - anchorX)); e.clientY,
const newH = Math.max(8, Math.abs(pointerY - anchorY)); centerClientX,
centerClientY,
origin.rotationDeg ?? 0,
);
const newW = Math.max(8, Math.abs(local.x - anchorX));
const newH = Math.max(8, Math.abs(local.y - anchorY));
const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH)); const nextScale = Math.max(newW / Math.max(1, baseW), newH / Math.max(1, baseH));
const ww = baseW * nextScale; const ww = baseW * nextScale;
const hh = baseH * nextScale; const hh = baseH * nextScale;
@@ -165,9 +243,14 @@ export function MaterialOverlay({
nextTop = anchorY; nextTop = anchorY;
} }
const localCenterX = nextLeft + ww / 2;
const localCenterY = nextTop + hh / 2;
const screenOffset = localToScreenOffset(localCenterX, localCenterY, origin.rotationDeg ?? 0);
onLayoutChange({ onLayoutChange({
cx: (nextLeft + ww / 2) / Math.max(1, viewW), ...origin,
cy: (nextTop + hh / 2) / Math.max(1, viewH), cx: origin.cx + screenOffset.x / Math.max(1, viewW),
cy: origin.cy + screenOffset.y / Math.max(1, viewH),
scale: nextScale, scale: nextScale,
}); });
}; };
@@ -182,19 +265,42 @@ export function MaterialOverlay({
if (!editable || !onLayoutChange || zoomTool) return; if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
dragRef.current = if (mode === 'move') {
mode === 'move' dragRef.current = {
? { mode: 'move', startX: e.clientX, startY: e.clientY, origin: { ...layout } } mode: 'move',
: {
mode: 'resize',
corner: corner ?? 'se',
startX: e.clientX, startX: e.clientX,
startY: e.clientY, startY: e.clientY,
origin: { ...layout }, origin: { ...effectiveLayout },
};
} else {
const center = layoutCenterClient();
dragRef.current = {
mode: 'resize',
corner: corner ?? 'se',
origin: { ...effectiveLayout },
baseW: base.w, baseW: base.w,
baseH: base.h, baseH: base.h,
viewW: view.w, viewW: view.w,
viewH: view.h, viewH: view.h,
centerClientX: center.x,
centerClientY: center.y,
};
}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag);
};
const startRotate = (e: React.PointerEvent) => {
if (!editable || !onLayoutChange || zoomTool) return;
e.preventDefault();
e.stopPropagation();
const center = layoutCenterClient();
dragRef.current = {
mode: 'rotate',
origin: { ...effectiveLayout },
startPointerAngle: pointerAngleDeg(center.x, center.y, e.clientX, e.clientY),
centerX: center.x,
centerY: center.y,
}; };
window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', endDrag); window.addEventListener('pointerup', endDrag);
@@ -220,7 +326,14 @@ export function MaterialOverlay({
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''] className={[styles.frame, editable && !zoomTool ? styles.frameEditable : '']
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ left, top, width: w, height: h }} style={{
left,
top,
width: w,
height: h,
transform: `rotate(${String(rotationDeg)}deg)`,
transformOrigin: 'center center',
}}
onPointerDown={(e) => { onPointerDown={(e) => {
if (zoomTool) return; if (zoomTool) return;
startDrag(e, 'move'); startDrag(e, 'move');
@@ -232,9 +345,9 @@ export function MaterialOverlay({
alt="" alt=""
draggable={false} draggable={false}
style={{ style={{
width: contentW, width: w,
height: contentH, height: h,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`, transform: 'translate(-50%, -50%)',
}} }}
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
@@ -252,6 +365,17 @@ export function MaterialOverlay({
/> />
)) ))
: null} : null}
{editable && !zoomTool && onLayoutChange ? (
<button
type="button"
className={styles.frameRotate}
aria-label={rotateLabel}
title={rotateLabel}
onPointerDown={startRotate}
>
<RotateIcon />
</button>
) : null}
</div> </div>
{showClose ? ( {showClose ? (
<button <button
+13 -2
View File
@@ -8,6 +8,8 @@ export type MaterialsOverlayLayout = {
cy: number; cy: number;
/** Масштаб относительно «базового contain» (1 = по умолчанию). */ /** Масштаб относительно «базового contain» (1 = по умолчанию). */
scale: number; scale: number;
/** Свободный угол поворота на сессии, градусы. */
rotationDeg: number;
}; };
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null; export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
@@ -24,21 +26,30 @@ export const DEFAULT_MATERIALS_OVERLAY_LAYOUT: MaterialsOverlayLayout = {
cx: 0.5, cx: 0.5,
cy: 0.5, cy: 0.5,
scale: 1, scale: 1,
rotationDeg: 0,
}; };
export type MaterialsOverlayEvent = export type MaterialsOverlayEvent =
| { kind: 'show'; materialId: MaterialId } | { kind: 'show'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'hide' } | { kind: 'hide' }
| { kind: 'toggle'; materialId: MaterialId } | { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'layout.set'; layout: MaterialsOverlayLayout } | { kind: 'layout.set'; layout: MaterialsOverlayLayout }
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool } | { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number }; | { kind: 'zoomAt'; nx: number; ny: number };
/** Нормализация угла в диапазон [0, 360). */
export function normalizeMaterialsRotation(deg: number): number {
if (!Number.isFinite(deg)) return 0;
const n = deg % 360;
return n < 0 ? n + 360 : n;
}
export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout { export function clampMaterialsLayout(layout: MaterialsOverlayLayout): MaterialsOverlayLayout {
return { return {
cx: Math.min(1.2, Math.max(-0.2, layout.cx)), cx: Math.min(1.2, Math.max(-0.2, layout.cx)),
cy: Math.min(1.2, Math.max(-0.2, layout.cy)), cy: Math.min(1.2, Math.max(-0.2, layout.cy)),
scale: Math.min(8, Math.max(0.15, layout.scale)), scale: Math.min(8, Math.max(0.15, layout.scale)),
rotationDeg: normalizeMaterialsRotation(layout.rotationDeg ?? 0),
}; };
} }