贴图轴点

This commit is contained in:
tianmo committed 2026-10-22 11:46:00 +08:00
1 parent c041503004
commit 4d54c87681
13 files changed
+2294 -1248

No files matched your search

+120 -1
View File
@@ -57,12 +57,64 @@
</button>
</div>
<slot />
<div v-if="supportsPivot" class="pivot-toggle-row">
<div class="pivot-toggle-icon">◎</div>
<div class="pivot-toggle-text">
<div class="pivot-toggle-title">贴图轴点</div>
<div class="pivot-toggle-sub" :title="pivotSummary">{{ pivotSummary }}</div>
</div>
<label class="pivot-switch" :title="pivotOpen ? '收起轴点调整' : '展开轴点调整'">
<input v-model="pivotOpen" type="checkbox" />
<span class="pivot-switch-ui"></span>
</label>
</div>
<PivotEditor
v-if="supportsPivot && pivotOpen"
:preview-url="displayPreviewUrl"
:anchor-x="resource.anchorX ?? 0.5"
:anchor-y="resource.anchorY ?? 0.5"
@update:anchorX="resource.anchorX = $event"
@update:anchorY="resource.anchorY = $event"
@fullscreen="openPivotFullscreen"
/>
</div>
<Teleport to="body">
<div v-if="pivotFullscreen" class="pivot-modal-backdrop" @pointerdown.self="closePivotFullscreen(false)">
<div class="pivot-modal" role="dialog" aria-modal="true" aria-label="贴图旋转轴点大图精修">
<div class="pivot-modal-head">
<div class="pivot-modal-icon">◎</div>
<div class="pivot-modal-info">
<div class="pivot-modal-title">
贴图旋转轴点大图精修
<span class="pivot-modal-badge">{{ resource.textureName || defaultFile }}</span>
</div>
<div class="pivot-modal-desc">在下方贴图上任意点击或拖拽,精确设置粒子的旋转/摆动中心</div>
</div>
<button type="button" class="pivot-modal-close" title="关闭" @click="closePivotFullscreen(false)">✕</button>
</div>
<div class="pivot-modal-hint">支持 100% ~ 400% 放大,精确对准尖端或根部:</div>
<PivotEditor
large
:preview-url="displayPreviewUrl"
:anchor-x="draftX"
:anchor-y="draftY"
@update:anchorX="draftX = $event"
@update:anchorY="draftY = $event"
/>
<div class="pivot-modal-actions">
<button type="button" class="pivot-modal-cancel" @click="closePivotFullscreen(false)">取消</button>
<button type="button" class="pivot-modal-confirm" @click="closePivotFullscreen(true)">确定并保存轴点</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import NumSlider from './NumSlider.vue'
import PivotEditor from './PivotEditor.vue'
type CommonImageResource = {
id: number
@@ -73,6 +125,8 @@ type CommonImageResource = {
sequenceFrames?: Array<{ name: string; previewUrl: string }>
textureName: string
textureFolder?: string
anchorX?: number
anchorY?: number
weight: number
locked: boolean
}
@@ -115,6 +169,43 @@ const displayPreviewUrl = computed(() => props.resource.imageMode === 'sequence'
? props.resource.sequenceFrames?.[0]?.previewUrl || ''
: props.resource.previewUrl)
const pivotOpen = ref(false)
const pivotFullscreen = ref(false)
const draftX = ref(0.5)
const draftY = ref(0.5)
const supportsPivot = computed(() => typeof props.resource.anchorX === 'number' && typeof props.resource.anchorY === 'number')
const pivotCentered = computed(() =>
Math.abs((props.resource.anchorX ?? 0.5) - 0.5) < 1e-4 && Math.abs((props.resource.anchorY ?? 0.5) - 0.5) < 1e-4,
)
const pivotSummary = computed(() => {
const x = (Math.min(1, Math.max(0, props.resource.anchorX ?? 0.5)) * 100).toFixed(1)
const y = (Math.min(1, Math.max(0, props.resource.anchorY ?? 0.5)) * 100).toFixed(1)
if (pivotOpen.value) return `已开启:X ${x}% | Y ${y}%`
if (pivotCentered.value) return '未开启,默认围绕图片正中心 (50%, 50%)'
return `轴点已自定义:X ${x}% | Y ${y}%`
})
function openPivotFullscreen() {
draftX.value = props.resource.anchorX ?? 0.5
draftY.value = props.resource.anchorY ?? 0.5
pivotFullscreen.value = true
}
function closePivotFullscreen(commit: boolean) {
if (commit) {
props.resource.anchorX = draftX.value
props.resource.anchorY = draftY.value
}
pivotFullscreen.value = false
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && pivotFullscreen.value) closePivotFullscreen(false)
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
const sequenceOrderText = computed(() => {
const frames = props.resource.sequenceFrames || []
if (!frames.length) return '未选择序列帧'
@@ -156,4 +247,32 @@ function pickImage() {
.weight-lock:hover:not(:disabled) { border-color: #7774ff; color: #b8b6ff; }
.weight-lock.on { border-color: #7774ff; background: #514fd0; color: #fff; }
.weight-lock:disabled { opacity: 0.35; cursor: not-allowed; }
.pivot-toggle-row { display: flex; align-items: center; gap: 9px; margin-top: 9px; padding: 8px 9px; border: 1px solid #2c3a54; border-radius: 8px; background: #131b28; }
.pivot-toggle-icon { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 32px; border: 1px solid #35435c; border-radius: 7px; background: #1c2638; color: #8f8cff; font-size: 17px; }
.pivot-toggle-text { flex: 1; min-width: 0; }
.pivot-toggle-title { color: #d6deee; font-size: 12px; font-weight: 600; }
.pivot-toggle-sub { margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #7584a0; font-size: 9px; }
.pivot-switch { position: relative; flex: 0 0 auto; cursor: pointer; }
.pivot-switch input { position: absolute; opacity: 0; pointer-events: none; }
.pivot-switch-ui { position: relative; display: block; width: 34px; height: 19px; border-radius: 10px; background: #354258; transition: background .16s ease; }
.pivot-switch-ui::after { content: ''; position: absolute; left: 2px; top: 2px; width: 15px; height: 15px; border-radius: 50%; background: #fff; transition: transform .16s ease; }
.pivot-switch input:checked + .pivot-switch-ui { background: #625ff1; }
.pivot-switch input:checked + .pivot-switch-ui::after { transform: translateX(15px); }
.pivot-modal-backdrop { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(6, 9, 15, .78); backdrop-filter: blur(2px); }
.pivot-modal { display: flex; flex-direction: column; width: min(980px, 94vw); max-height: 92vh; overflow: auto; padding: 14px; border: 1px solid #2f3f5c; border-radius: 14px; background: #121927; box-shadow: 0 24px 70px rgba(0, 0, 0, .6); }
.pivot-modal-head { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 10px; border-bottom: 1px solid #253148; }
.pivot-modal-icon { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex: 0 0 34px; border: 1px solid #3b4a66; border-radius: 8px; background: #1c2638; color: #8f8cff; font-size: 18px; }
.pivot-modal-info { flex: 1; min-width: 0; }
.pivot-modal-title { display: flex; align-items: center; gap: 7px; color: #e4e9f5; font-size: 15px; font-weight: 700; }
.pivot-modal-badge { padding: 2px 7px; border: 1px solid #4b4a8f; border-radius: 999px; background: #2c2b58; color: #b9b6ff; font-size: 10px; font-weight: 500; }
.pivot-modal-desc { margin-top: 3px; color: #7f8daa; font-size: 11px; }
.pivot-modal-close { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #8b98b3; font-size: 15px; cursor: pointer; }
.pivot-modal-close:hover { background: #253148; color: #fff; }
.pivot-modal-hint { margin: 10px 0 0; color: #8b98b3; font-size: 11px; }
.pivot-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid #253148; }
.pivot-modal-cancel { height: 34px; padding: 0 16px; border: 1px solid #34435e; border-radius: 7px; background: #1a2334; color: #a9b6d0; font-size: 12px; cursor: pointer; }
.pivot-modal-cancel:hover { border-color: #5d5aa8; color: #fff; }
.pivot-modal-confirm { height: 34px; padding: 0 22px; border: 1px solid #7774ff; border-radius: 7px; background: #5b57e8; color: #fff; font-size: 12px; font-weight: 700; cursor: pointer; }
.pivot-modal-confirm:hover { background: #6a66ff; }
</style>
+393
View File
@@ -0,0 +1,393 @@
<template>
<div class="pivot-editor" :class="{ 'pivot-large': large }">
<div class="pivot-toolbar">
<div class="pivot-hint">
<span class="pivot-hint-icon">⊕</span>
<span>贴图轴点</span>
</div>
<div class="pivot-zoom">
<button type="button" :disabled="zoom <= 100" title="缩小" @click="zoomBy(-25)">−</button>
<span>{{ zoom }}%</span>
<button type="button" :disabled="zoom >= 400" title="放大" @click="zoomBy(25)">+</button>
</div>
<button v-if="!large" type="button" class="pivot-expand" title="放大到全屏操作" @click="$emit('fullscreen')">⤢</button>
</div>
<div
ref="stageRef"
class="pivot-stage"
:class="{ panning }"
title="左键点击/拖动设置轴点 · 右键拖动画布 · 滚轮缩放"
@pointerdown="onPointerDown"
@wheel.prevent="onWheel"
@contextmenu.prevent
>
<div v-if="previewUrl" ref="boxRef" class="pivot-box" :style="boxStyle">
<img :src="previewUrl" alt="轴点预览" draggable="false" @load="onImageLoad" />
</div>
<template v-if="previewUrl">
<!-- 准线与轴点放在缩放容器之外:尺寸恒定,准线贯穿整个交互区。 -->
<div class="pivot-cross vertical" :style="{ left: `${pivotScreen.x}px` }"></div>
<div class="pivot-cross horizontal" :style="{ top: `${pivotScreen.y}px` }"></div>
<div class="pivot-marker" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }"></div>
<div class="pivot-badge" :style="{ left: `${pivotScreen.x}px`, top: `${pivotScreen.y}px` }">X: {{ anchorXText }}% | Y: {{ anchorYText }}%</div>
</template>
<div v-else class="pivot-empty">未选择图片</div>
</div>
<div class="pivot-bottom">
<div class="pivot-card">
<div class="pivot-presets">
<button
v-for="preset in presets"
:key="preset.label"
type="button"
class="pivot-preset"
:class="{ on: isPreset(preset) }"
:title="`${preset.label}(${preset.x}%, ${preset.y}%)`"
:aria-label="preset.label"
@click="pickPreset(preset)"
>{{ preset.icon }}</button>
</div>
<button type="button" class="pivot-reset" @click="resetCenter">↺ 复位居中 (50%, 50%)</button>
</div>
<div class="pivot-card">
<div class="pivot-card-head"><span><span class="pivot-card-icon">◎</span> 轴点坐标(%)</span></div>
<div class="pivot-coords">
<label>X:
<input type="number" min="0" max="100" step="0.1" :value="anchorXText" @input="updateCoord('x', $event)" />
<span>%</span>
</label>
<label>Y:
<input type="number" min="0" max="100" step="0.1" :value="anchorYText" @input="updateCoord('y', $event)" />
<span>%</span>
</label>
</div>
<!-- 旋转测试只在大图精修时显示,避免内联面板过高。 -->
<template v-if="large">
<div class="pivot-card-head pivot-rotate-head">
<span><span class="pivot-card-icon">✥</span> 轴心旋转测试</span>
<span class="pivot-rotate-state">{{ rotationMode === 'static' ? '静止' : rotationMode === 'spin' ? '自转' : '摆动' }}</span>
</div>
<div class="pivot-rotate-body">
<div class="pivot-rotate-modes">
<button type="button" :class="{ on: rotationMode === 'static' }" @click="rotationMode = 'static'">静止</button>
<button type="button" :class="{ on: rotationMode === 'spin' }" @click="rotationMode = 'spin'">↻ 自转</button>
<button type="button" :class="{ on: rotationMode === 'swing' }" @click="rotationMode = 'swing'">⇄ 摆动</button>
</div>
<div class="pivot-rotate-preview">
<img
v-if="previewUrl"
:src="previewUrl"
alt="旋转测试"
draggable="false"
:class="rotationMode"
:style="rotateStyle"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
previewUrl: string
anchorX: number
anchorY: number
large?: boolean
}>(), {
large: false,
})
const emit = defineEmits<{
'update:anchorX': [number]
'update:anchorY': [number]
fullscreen: []
}>()
const presets = [
{ icon: '↖', label: '左上', x: 0, y: 0 },
{ icon: '↑', label: '正上', x: 50, y: 0 },
{ icon: '↗', label: '右上', x: 100, y: 0 },
{ icon: '←', label: '正左', x: 0, y: 50 },
{ icon: '◯', label: '正中', x: 50, y: 50 },
{ icon: '→', label: '正右', x: 100, y: 50 },
{ icon: '↙', label: '左下', x: 0, y: 100 },
{ icon: '↓', label: '正下·底部', x: 50, y: 100 },
{ icon: '↘', label: '右下', x: 100, y: 100 },
]
const stageRef = ref<HTMLElement | null>(null)
const boxRef = ref<HTMLElement | null>(null)
const stageSize = ref({ w: 0, h: 0 })
const natural = ref({ w: 1, h: 1 })
const zoom = ref(100)
const pan = ref({ x: 0, y: 0 })
const panning = ref(false)
const rotationMode = ref<'static' | 'spin' | 'swing'>('static')
let observer: ResizeObserver | null = null
const clampPercent = (value: number) => Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0))
const anchorXText = computed(() => (clampPercent(props.anchorX * 100)).toFixed(1))
const anchorYText = computed(() => (clampPercent(props.anchorY * 100)).toFixed(1))
function contain(width: number, height: number) {
const ratio = natural.value.w / Math.max(1, natural.value.h)
let w = width
let h = w / ratio
if (h > height) { h = height; w = h * ratio }
return { w: Math.max(1, w), h: Math.max(1, h) }
}
const fit = computed(() => {
const pad = props.large ? 26 : 12
const width = Math.max(1, stageSize.value.w - pad * 2)
const height = Math.max(1, stageSize.value.h - pad * 2)
return contain(width, height)
})
const boxStyle = computed(() => ({
width: `${fit.value.w}px`,
height: `${fit.value.h}px`,
transform: `translate(${pan.value.x}px, ${pan.value.y}px) scale(${zoom.value / 100})`,
}))
/** 轴点在交互区中的屏幕坐标:跟随缩放/平移,但轴点本身不随缩放变大。 */
const pivotScreen = computed(() => {
const scale = zoom.value / 100
return {
x: stageSize.value.w / 2 + pan.value.x + (clampPercent(props.anchorX * 100) / 100 - 0.5) * fit.value.w * scale,
y: stageSize.value.h / 2 + pan.value.y + (clampPercent(props.anchorY * 100) / 100 - 0.5) * fit.value.h * scale,
}
})
const rotateStyle = computed(() => {
const size = props.large ? 128 : 72
const box = contain(size, size)
return {
width: `${box.w}px`,
height: `${box.h}px`,
transformOrigin: `${clampPercent(props.anchorX * 100)}% ${clampPercent(props.anchorY * 100)}%`,
}
})
function setAnchor(x: number, y: number) {
emit('update:anchorX', Math.min(1, Math.max(0, Number.isFinite(x) ? x : 0)))
emit('update:anchorY', Math.min(1, Math.max(0, Number.isFinite(y) ? y : 0)))
}
function updateFromPointer(event: PointerEvent) {
const box = boxRef.value
if (!box) return
const rect = box.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
setAnchor(
(event.clientX - rect.left) / rect.width,
(event.clientY - rect.top) / rect.height,
)
}
function onPointerDown(event: PointerEvent) {
if (!props.previewUrl) return
if (event.button === 2) {
startPan(event)
return
}
if (event.button !== 0) return
event.preventDefault()
updateFromPointer(event)
const move = (ev: PointerEvent) => updateFromPointer(ev)
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
}
/** 右键拖动画布:只平移视图,不改变轴点。 */
function startPan(event: PointerEvent) {
event.preventDefault()
panning.value = true
const startX = event.clientX
const startY = event.clientY
const originX = pan.value.x
const originY = pan.value.y
const move = (ev: PointerEvent) => {
pan.value = { x: originX + ev.clientX - startX, y: originY + ev.clientY - startY }
}
const up = () => {
panning.value = false
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
window.removeEventListener('pointercancel', up)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
window.addEventListener('pointercancel', up)
}
/** 滚轮以鼠标位置为中心缩放画布,100% ~ 400%。 */
function onWheel(event: WheelEvent) {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12
const nextZoom = Math.min(400, Math.max(100, Math.round(zoom.value * factor)))
if (nextZoom === zoom.value) return
const stage = stageRef.value
if (!stage) {
zoom.value = nextZoom
return
}
const rect = stage.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const ratio = nextZoom / zoom.value
// 未变换时图片盒中心与舞台中心重合;缩放时保持鼠标下的点不动。
pan.value = {
x: pan.value.x + (cursorX - rect.width / 2 - pan.value.x) * (1 - ratio),
y: pan.value.y + (cursorY - rect.height / 2 - pan.value.y) * (1 - ratio),
}
zoom.value = nextZoom
}
function onImageLoad(event: Event) {
const image = event.target as HTMLImageElement
natural.value = { w: image.naturalWidth || 1, h: image.naturalHeight || 1 }
}
function zoomBy(delta: number) {
zoom.value = Math.min(400, Math.max(100, zoom.value + delta))
}
function resetCenter() {
setAnchor(0.5, 0.5)
}
function pickPreset(preset: { x: number; y: number }) {
setAnchor(preset.x / 100, preset.y / 100)
}
function isPreset(preset: { x: number; y: number }) {
return Math.abs(clampPercent(props.anchorX * 100) - preset.x) < 0.05
&& Math.abs(clampPercent(props.anchorY * 100) - preset.y) < 0.05
}
function updateCoord(axis: 'x' | 'y', event: Event) {
const value = clampPercent(Number((event.target as HTMLInputElement).value))
if (axis === 'x') emit('update:anchorX', value / 100)
else emit('update:anchorY', value / 100)
}
watch(() => props.previewUrl, () => {
natural.value = { w: 1, h: 1 }
pan.value = { x: 0, y: 0 }
zoom.value = 100
})
onMounted(() => {
if (!stageRef.value) return
observer = new ResizeObserver((entries) => {
const rect = entries[0]?.contentRect
if (rect) stageSize.value = { w: rect.width, h: rect.height }
})
observer.observe(stageRef.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
</script>
<style scoped>
.pivot-editor { margin-top: 8px; padding: 8px; border: 1px solid #2e3c55; border-radius: 8px; background: #121a27; }
.pivot-toolbar { display: flex; align-items: center; gap: 7px; margin-bottom: 7px; }
.pivot-hint { flex: 1; min-width: 0; display: flex; align-items: center; gap: 5px; color: #9aa8c4; font-size: 10px; line-height: 1.3; }
.pivot-hint-icon { color: #8f8cff; font-size: 13px; }
.pivot-zoom { display: flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid #34435e; border-radius: 6px; background: #182234; }
.pivot-zoom button { width: 20px; height: 20px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #a9b6d0; font-size: 13px; cursor: pointer; }
.pivot-zoom button:hover:not(:disabled) { background: #2a3853; color: #fff; }
.pivot-zoom button:disabled { opacity: .3; cursor: default; }
.pivot-zoom span { min-width: 36px; text-align: center; color: #c7d1e6; font-size: 10px; font-variant-numeric: tabular-nums; }
.pivot-expand { width: 24px; height: 24px; padding: 0; border: 1px solid #34435e; border-radius: 6px; background: #182234; color: #a9b6d0; font-size: 13px; cursor: pointer; }
.pivot-expand:hover { border-color: #7774ff; color: #fff; }
.pivot-stage {
position: relative; display: flex; align-items: center; justify-content: center;
width: 100%; height: 156px; overflow: hidden;
border: 1px solid #2f3d57; border-radius: 8px;
background-color: #10161f;
background-image:
linear-gradient(45deg, #1a2230 25%, transparent 25%),
linear-gradient(-45deg, #1a2230 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a2230 75%),
linear-gradient(-45deg, transparent 75%, #1a2230 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
touch-action: none; cursor: crosshair;
}
.pivot-large .pivot-stage { height: 340px; background-size: 22px 22px; background-position: 0 0, 0 11px, 11px -11px, -11px 0; }
.pivot-stage.panning { cursor: grabbing; }
.pivot-box { position: relative; flex: 0 0 auto; }
.pivot-box > img { display: block; width: 100%; height: 100%; object-fit: fill; user-select: none; pointer-events: none; }
.pivot-empty { color: #4e5a70; font-size: 12px; }
.pivot-cross { position: absolute; pointer-events: none; background: rgba(126, 118, 255, .75); box-shadow: 0 0 3px rgba(126, 118, 255, .6); }
.pivot-cross.vertical { top: 0; bottom: 0; width: 1px; transform: translateX(-.5px); }
.pivot-cross.horizontal { left: 0; right: 0; height: 1px; transform: translateY(-.5px); }
.pivot-marker {
position: absolute; width: 13px; height: 13px; transform: translate(-50%, -50%);
border: 2px solid #c9c6ff; border-radius: 50%;
background: rgba(96, 95, 240, .85);
box-shadow: 0 0 0 3px rgba(96, 95, 240, .28), 0 0 10px rgba(96, 95, 240, .8);
pointer-events: none;
}
.pivot-badge {
position: absolute; transform: translate(-50%, 14px);
padding: 2px 6px; border: 1px solid #6f6bc7; border-radius: 4px;
background: rgba(25, 28, 58, .92); color: #b9b6ff;
font-size: 9px; white-space: nowrap; pointer-events: none;
}
.pivot-bottom { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; margin-top: 8px; }
.pivot-large .pivot-bottom { gap: 14px; margin-top: 14px; }
.pivot-card { min-width: 0; padding: 7px; border: 1px solid #28344a; border-radius: 8px; background: #151d2b; }
.pivot-card-head { display: flex; align-items: center; justify-content: space-between; gap: 5px; margin-bottom: 6px; color: #b9c4da; font-size: 10px; }
.pivot-card-icon { color: #8f8cff; }
.pivot-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
.pivot-preset { height: 30px; padding: 0; border: 1px solid #2f3d57; border-radius: 5px; background: #1b2537; color: #aab6cf; font-size: 16px; line-height: 1; cursor: pointer; }
.pivot-preset:hover { border-color: #5d5aa8; color: #d9d8ff; }
.pivot-preset.on { border-color: #7774ff; background: #4a47b5; color: #fff; }
.pivot-reset { display: block; width: 100%; height: 26px; margin-top: 6px; padding: 0 6px; border: 1px solid #2f3d57; border-radius: 5px; background: #1a2435; color: #96a4c0; font-size: 10px; cursor: pointer; white-space: nowrap; }
.pivot-reset:hover { border-color: #5d5aa8; background: #232f46; color: #d9d8ff; }
.pivot-coords { display: flex; flex-direction: column; gap: 5px; }
.pivot-large .pivot-coords { flex-direction: row; gap: 8px; }
.pivot-coords label { flex: 1; display: flex; align-items: center; gap: 3px; color: #8d9ab5; font-size: 10px; }
.pivot-coords input { width: 100%; min-width: 0; height: 24px; padding: 0 4px; border: 1px solid #34435e; border-radius: 4px; background: #101827; color: #f0b465; font-size: 11px; text-align: right; }
.pivot-rotate-head { margin-top: 10px; }
.pivot-rotate-state { color: #6f7c96; font-size: 9px; }
.pivot-rotate-body { display: flex; align-items: center; gap: 7px; }
.pivot-rotate-modes { display: flex; flex-direction: column; gap: 4px; flex: 1; }
.pivot-rotate-modes button { height: 24px; padding: 0 5px; border: 1px solid #2f3d57; border-radius: 5px; background: #1b2537; color: #93a0ba; font-size: 9px; cursor: pointer; }
.pivot-rotate-modes button.on { border-color: #7774ff; background: #4a47b5; color: #fff; }
.pivot-rotate-preview { display: flex; align-items: center; justify-content: center; width: 78px; height: 78px; flex: 0 0 78px; border: 1px solid #2b374d; border-radius: 6px; background: #10161f; overflow: hidden; }
.pivot-large .pivot-rotate-preview { width: 128px; height: 128px; flex-basis: 128px; }
.pivot-rotate-preview img { display: block; }
.pivot-rotate-preview img.spin { animation: pivot-spin 2s linear infinite; }
.pivot-rotate-preview img.swing { animation: pivot-swing 1.1s ease-in-out infinite alternate; }
@keyframes pivot-spin { to { transform: rotate(360deg); } }
@keyframes pivot-swing { from { transform: rotate(-28deg); } to { transform: rotate(28deg); } }
.pivot-large .pivot-hint { font-size: 12px; }
.pivot-large .pivot-card-head { font-size: 12px; }
.pivot-large .pivot-preset { height: 42px; font-size: 20px; }
.pivot-large .pivot-reset { height: 34px; margin-top: 8px; font-size: 12px; }
.pivot-large .pivot-coords input { height: 34px; font-size: 14px; }
.pivot-large .pivot-rotate-modes button { height: 34px; font-size: 12px; }
</style>
@@ -100,7 +100,6 @@
@toggle-lock="toggleResourceLock"
>
<NumSlider label="缩放" :min="0.1" :max="3" :step="0.05" v-model="resource.scale" />
<label class="row"><span>轴心 x,y</span><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorX" /><input type="number" step="0.05" class="inp tiny" v-model.number="resource.anchorY" /></label>
<div class="resource-divider"></div>
<label class="row resource-color-row"><span>颜色</span>