跳至正文
组件

Isometric Mark等轴测标志

把任意平面多边形挤出成等轴测线条 3D:多种顶面材质与地面、跟随鼠标的线条高光、点击按下与音效。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/isometric-mark.json

用法

传入平面形状即可,坐标就是平面 logo 自己的坐标(y 向下)。形状数组请定义在组件外面,避免每次渲染重新计算几何。

app/brand-mark.tsx
import {
  IsometricMark,
  type IsometricShape,
} from "@/components/ui/isometric-mark/isometric-mark"

const SHAPES: readonly IsometricShape[] = [
  {
    outer: [
      [0, 0],
      [100, 0],
      [100, 100],
      [0, 100],
    ],
    holes: [
      [
        [35, 35],
        [65, 35],
        [65, 65],
        [35, 65],
      ],
    ],
  },
]

export function BrandMark() {
  return <IsometricMark shapes={SHAPES} label="我的标志" className="w-64" />
}

宽度由 className 控制,高度按 viewBox 自动适配。线宽、斜纹间距、虚线都跟着图形尺寸等比缩放,所以放大缩小时观感一致。

朝向与视角

rotation 在投影前把图形绕中心旋转,决定它平躺在地上的朝向;elevation 是视线的俯仰角,默认约 35.26°(标准等轴测),调大更接近俯视,调小更接近平视。

取 0、90、180、-90 时图形的横竖边沿等轴测的两条斜轴,像一块平放的板;斜向的图形可以取任意角度,让它的长边顺着某条斜轴更好看。页首的本站标志用的是 rotation={-65}:

<IsometricMark shapes={SHAPES} rotation={-65} label="易" guides="edges" />

想直观比较不同朝向和俯仰,打开下方任一示例的「调整」面板拖动即可。

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整朝向、俯仰、挤出高度、顶面材质、地面、辅助线与音色,并开关按下、高光与音效;未改动的项显示示例自己的取值。调整时画框固定,容器大小不变。

多个形状与色调

shapes 可以放多块互不相连的多边形,它们之间的遮挡自动计算;每块可以用 tone 单独着色。下面是 shadcn/ui 的标志:两道斜杠各是一块,分别取主题色的深浅,地面用条纹 floor="lines"。

正在加载…

带孔的形状

holes 在外轮廓里挖孔,孔壁同样按朝向自动消隐。下面是 Pi 的像素标志:左边一块挖出方孔,右下的方块独立成一块并着主题色,顶面用网纹材质,地面用棋盘格 floor="checker"。

正在加载…

静态图形

任何多边形都可以,最简单的一个三角形也行。下面是 Vercel 的三角标:素面材质(material="plain")、包围盒辅助线(guides),关掉了 highlight;需要完全静态的插图时再关掉 interactive。地面用站点的点阵底纹 floor="dots"。

正在加载…

材质、地面与辅助线

  • material 是顶面材质:hatch 斜纹「/」(默认)、hatch-reverse 斜纹「\」、cross 网纹、dots 点阵、lines 横纹、plain 素面。斜纹与图形长边接近平行时会有视差感,换一个方向或用横纹即可。
  • guides 是背景虚线:true / "box" 画包围盒四边的延长线,适合横平竖直的图形;"edges" 沿图形自己最长的两个边方向画外侧切线,适合斜向的图形。
  • floor 是地面,和辅助线同方向、同一平面,并对齐图形的每条边:true / "grid" 方格网、"lines" 条纹、"checker" 棋盘格,"dots" 则是站点的点阵底纹(8px 间距、1px 方点,铺在屏幕平面上)。

挤出高度与按下

depth 是挤出高度,单位与形状坐标相同,默认取图形平面尺寸的 12%。按住指针时图形被压到 pressedDepth(默认是 depth 的一半,设为 0 会被完全压平),松开或移出后弹回;线条高光跟随鼠标在窗口中的位置。触屏与“减少动态效果”下高光自动关闭,按下状态瞬间切换。这些都可以在示例的调整面板里直接试。

点击音效

sound 打开按下与松开时的合成音效(Web Audio 现场生成,不需要音频文件),默认关闭,本页所有示例都已打开。浏览器要求先有用户操作才能出声,所以音频在第一次按下时才初始化。静音偏好存在浏览器本地,页面上所有标志共用:useIsometricMarkMuted() 返回 [muted, setMuted],可以用它做一个静音按钮,页首的示例右上角就是这样。这份偏好与站内其他带音效的组件共用(localStorage 的 travinsy:sound:muted):同一页面里一处静音,其他组件的静音按钮与音效立刻跟上;换到另一个标签页也会同步。soundTone 调音色:0 偏闷,是低沉的「咚」;1 偏脆,是清脆的「咔」,默认 0.6。

const [muted, setMuted] = useIsometricMarkMuted()

<IsometricMark shapes={SHAPES} sound soundTone={0.6} />
<button onClick={() => setMuted(!muted)}>{muted ? "打开音效" : "静音"}</button>

几何说明

组件不依赖手工描的路径,所有面都由平面多边形自动算出:

  • 投影:X = (x - y)·cos30°,Y = (x + y)·k₁ - z·k₂,其中 k₁ = sin(e)·√2·cos30°、k₂ = cos(e)·√2·cos30°,e 是 elevation;默认 35.26° 时 k₁ = 0.5、k₂ = 1,即标准等轴测。观察者位于 (+x, +y, +z) 一侧,viewBox 按投影后的包围盒自动计算并留少量边距。
  • 面的组成:顶面在 z = depth,外轮廓与孔一起用 evenodd 填充;每个环的每条边挤出一面侧墙,孔被挤成通孔。
  • 背面剔除:外轮廓取正面积、孔取负面积,所有环的“实体外侧”法线统一为 (ey, -ex),只保留朝向观察者的墙。与观察方向垂直的墙在屏幕上面积为零,不绘制。
  • 消隐:画家算法。墙在屏幕上只占据 x - y 方向上的一段范围,范围重叠的两面墙比较重叠区间中点的 x + y,得到“谁挡住谁”,再做拓扑排序,凹多边形也能排对。顺序只取决于平面形状,按下动画时不用重排。顶面最后绘制。
  • 按下动画:一个弹簧驱动的高度值同时重算所有侧墙路径(只是 O(边数) 的字符串重建),顶面路径不变,整体用 y 偏移下移,材质纹理因此始终贴着轮廓。
  • 地面:辅助线与地面网格取同样的两个方向。每个方向上,若所有边到外侧切线的距离有足够大的公约数,就按它等距铺线;否则每条边各画一条线,外侧按平均间距延伸。

API

IsometricMark

其余属性透传到根 svg,className 与内置样式合并。

useIsometricMarkMuted

function useIsometricMarkMuted(): readonly [
  muted: boolean,
  setMuted: (muted: boolean) => void,
]

读写点击音效的静音偏好。偏好存在 localStorage 的 travinsy:sound:muted,与站内其他带音效的组件共用,同一页面的所有标志共享(写入后广播 travinsy:sound-muted-change 事件)并在标签页之间同步;服务端与水合阶段按未静音渲染。

类型

type IsometricPoint = readonly [number, number]

type IsometricShape = {
  /** 外轮廓,顺时针或逆时针都可以 */
  outer: readonly IsometricPoint[]
  /** 孔,会被挤成通孔 */
  holes?: readonly (readonly IsometricPoint[])[]
  /** 这块形状的色调(任意 CSS 颜色,如 var(--primary)):顶面与侧墙淡淡着色、线条取同色 */
  tone?: string
}

type IsometricMaterial =
  "hatch" | "hatch-reverse" | "cross" | "dots" | "lines" | "plain"

type IsometricFloor = "grid" | "lines" | "dots" | "checker"

页首的本站标志就用了 tone:上三道细斜杠用主题色由浅到深,下三道粗斜杠保持单色线稿。

data-slot

元素data-slot
根元素isometric-mark
单面侧墙isometric-mark-wall
顶面isometric-mark-top
背景辅助线isometric-mark-guides
地面isometric-mark-floor(data-floor 为具体材质)

可访问性

  • 不传 label 时根元素是 aria-hidden,读屏完全忽略,适合作为纯装饰;传入 label 后变成 role="img" 并带上 aria-label。
  • 按下效果只响应指针,没有键盘等价操作,不承载任何功能。需要可点击时请把它包进 a 或 button,由外层提供语义与键盘支持。
  • 高光在触屏设备((hover: none))与 prefers-reduced-motion 下自动关闭;减少动态效果时按下状态瞬间切换,不播放弹簧。
  • 音效默认关闭;打开后请同时提供静音入口(useIsometricMarkMuted),音效只是点缀,不承载信息。
  • 颜色由 --foreground、--background 与形状的 tone 混合得到;暗色模式下顶面、线条、纹理与着色比例单独加重,亮暗模式与颜色主题下都有足够的对比。

注意事项

  • 高光位置取鼠标在窗口中的相对位置并映射到图形范围,因此鼠标不在图形上时高光也会随之移动;图形不在视口内(含 80px 缓冲)时不监听鼠标。
  • 顶面与侧墙是实心填充(--top、--wall,亮色下等于背景色),用来遮挡后面的线,所以图形背后若有图案会被盖住;地面与背景辅助线画在最底层。
  • depth 默认取图形平面尺寸的 12%;pressedDepth 默认是 depth 的一半,设为 0 则会被完全压平。
  • 颜色变量 --stroke、--pattern、--guide、--top、--wall 以及着色比例 --tone-top、--tone-wall、--tone-line 定义在根元素的 class 里(亮暗各一套),可以用 className 覆盖,例如 [--stroke:var(--primary)]。
  • floor="dots" 按图形的实际渲染尺寸换算 8px,尺寸变化时自动重算。
  • 几何计算放在同目录的 geometry.ts,是不依赖 React 的纯函数;音效在 sound.ts。

源码

components/ui/isometric-mark/isometric-mark.tsx
"use client"

import * as React from "react"
import {
  motion,
  useInView,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react"

import { cn } from "@/lib/utils"

import {
  buildIsometricModel,
  ISOMETRIC_ELEVATION,
  rotateShapes,
  wallPath,
  type IsometricModel,
  type IsometricPoint,
  type IsometricShape,
  type IsometricWall,
} from "./geometry"
import { playPress, playRelease, useIsometricMarkMuted } from "./sound"

/** 按下 / 弹起共用的弹簧,手感接近真实按键。 */
const PRESS_SPRING = { stiffness: 200, damping: 18, mass: 0.5 }
/** 高光跟随鼠标的弹簧,比按下更“跟手”。 */
const LIGHT_SPRING = { stiffness: 300, damping: 30, mass: 0.1 }

type IsometricMarkProps = Omit<React.ComponentProps<"svg">, "children"> & {
  /**
   * 平面形状,坐标是平面 logo 自己的坐标(y 向下)。
   * 请在组件外定义为常量,每次渲染传入新数组会重新计算几何。
   */
  shapes: readonly IsometricShape[]
  /**
   * 投影前绕图形中心旋转的角度(度),用来选择图形平躺在地上的朝向。
   * 取 90 的整数倍时横竖边沿等轴测斜轴,像一块平放的板;斜向的图形可取任意角度,让长边顺着斜轴
   * @default 0
   */
  rotation?: number
  /**
   * 视线的俯仰角(度,5–85)。调大更接近俯视(地面更方、厚度更薄),调小更接近平视(地面更扁、厚度更高)
   * @default 35.26(标准等轴测)
   */
  elevation?: number
  /** 挤出高度,单位与形状坐标相同;默认取图形平面尺寸的 12% */
  depth?: number
  /** 按下后的挤出高度,默认是 `depth` 的一半;`0` 为完全压平 */
  pressedDepth?: number
  /**
   * 点击 / 触摸时是否有按下效果
   * @default true
   */
  interactive?: boolean
  /**
   * 线条是否有跟随鼠标的高光;触屏与“减少动态效果”下自动关闭
   * @default true
   */
  highlight?: boolean
  /**
   * 背景里的虚线辅助线。`true` 或 `"box"`:包围盒四边沿等轴测轴延长;
   * `"edges"`:沿图形自己最长的两个边方向画外侧切线(适合斜向的图形,辅助线贴着长边与齐平的端点)
   * @default false
   */
  guides?: boolean | "box" | "edges"
  /**
   * 顶面材质:`hatch` 斜纹「/」、`hatch-reverse` 斜纹「\」、`cross` 交叉网纹、`dots` 点阵、`lines` 横纹、`plain` 素面。
   * 斜纹与图形的长边接近平行时会产生视差感,换成另一个方向或横纹即可
   * @default "hatch"
   */
  material?: IsometricMaterial
  /**
   * 图形下方的地面,与辅助线同方向、同平面、对齐每条边,向四周渐隐。
   * `true` / `"grid"` 方格网、`"lines"` 只有一个方向的条纹、`"dots"` 站点的点阵底纹(屏幕平面,8px 间距)、`"checker"` 棋盘格
   * @default false
   */
  floor?: boolean | IsometricFloor
  /**
   * 按下与松开时播放合成音效(Web Audio,无需音频文件);尊重全站静音偏好,见 useIsometricMarkMuted
   * @default false
   */
  sound?: boolean
  /**
   * 音色:0 偏闷(低沉的「咚」),1 偏脆(清脆的「咔」)
   * @default 0.6
   */
  soundTone?: number
  /** 可访问名称;不传时整个图形对读屏隐藏 */
  label?: string
}

/** 单面侧墙:高度由弹簧驱动,墙路径随高度重算,顶边始终贴着顶面。 */
function IsometricMarkWall({
  wall,
  height,
  gradientId,
  heightScale,
}: {
  wall: IsometricWall
  height: MotionValue<number>
  gradientId: string
  heightScale: number
}) {
  const d = useTransform(height, (h) => wallPath(wall, h, heightScale))

  // 同一面先画底色线再画高光线,后画的近处面会盖住远处面的线;有色调时墙面极淡着色、线条取同色
  return (
    <g
      data-slot="isometric-mark-wall"
      style={
        wall.tone
          ? ({
              "--wall-fill": `color-mix(in oklab, ${wall.tone} var(--tone-wall), var(--wall))`,
              "--wall-stroke": `color-mix(in oklab, ${wall.tone} var(--tone-line), var(--background))`,
            } as React.CSSProperties)
          : undefined
      }
    >
      <motion.path
        d={d}
        fill="var(--wall-fill, var(--wall))"
        stroke="var(--wall-stroke, var(--stroke))"
      />
      <motion.path d={d} fill="none" stroke={`url(#${gradientId})`} />
    </g>
  )
}

type IsometricMarkTuning = Partial<
  Pick<
    IsometricMarkProps,
    | "rotation"
    | "depth"
    | "elevation"
    | "guides"
    | "material"
    | "interactive"
    | "highlight"
    | "sound"
    | "soundTone"
    | "floor"
  >
> & {
  /** 固定画框:沿用原属性下的 viewBox,调整时容器大小与图形比例都不变 */
  stableFrame?: boolean
  /** 回报示例自己的实际取值(未被覆盖前,含自动高度与默认俯仰),面板据此显示当前值 */
  onReport?: (values: IsometricMarkReport) => void
}

type IsometricMarkReport = {
  rotation: number
  elevation: number
  depth: number
  guides: "box" | "edges" | false
  material: IsometricMaterial
  interactive: boolean
  highlight: boolean
  sound: boolean
  soundTone: number
  floor: IsometricFloor | false
}

/**
 * 可选的调整上下文:子树里所有 IsometricMark 的对应属性被这里的值覆盖。
 * 用于调试面板一类的场景;不包这一层时组件行为不变。
 */
const IsometricMarkTuningContext = React.createContext<IsometricMarkTuning>({})

type IsometricFloor = "grid" | "lines" | "dots" | "checker"

type IsometricMaterial =
  "hatch" | "hatch-reverse" | "cross" | "dots" | "lines" | "plain"

/** 各材质在一个 size×size 平铺单元里的路径(u 为一个像素对应的长度) */
function materialPath(material: IsometricMaterial, size: number, u: number) {
  const slash = `M${-u} ${u}l${u * 2} ${-u * 2}M0 ${size}l${size} ${-size}M${size - u} ${size + u}l${u * 2} ${-u * 2}`
  const back = `M${-u} ${size - u}l${u * 2} ${u * 2}M0 0l${size} ${size}M${size - u} ${-u}l${u * 2} ${u * 2}`
  switch (material) {
    case "hatch":
      return slash
    case "hatch-reverse":
      return back
    case "cross":
      return slash + back
    case "lines":
      return `M0 ${size / 2}H${size}`
    default:
      return ""
  }
}

/** 把平面多边形挤出成等轴测线条 3D,带斜纹顶面、跟随鼠标的高光与按下效果。 */
function IsometricMark(allProps: IsometricMarkProps) {
  const tuning = React.useContext(IsometricMarkTuningContext)
  const { stableFrame, onReport, ...overrides } = tuning
  // 固定画框:沿用按原属性算出的 viewBox,调整时画框与图形比例都不变,
  // 容器既不长高也不把图形挤小;转到画框外的部分照常显示(svg overflow: visible)
  const {
    shapes,
    rotation = 0,
    depth,
    guides = false,
    elevation,
    material = "hatch",
    interactive = true,
    highlight = true,
    sound = false,
    soundTone = 0.6,
    floor = false,
  } = allProps
  const baseModel = React.useMemo(
    () =>
      stableFrame || onReport
        ? buildIsometricModel(
            rotateShapes(shapes, rotation),
            depth,
            guides === "edges" ? "edges" : "box",
            elevation,
          )
        : undefined,
    [stableFrame, onReport, shapes, rotation, depth, guides, elevation],
  )
  const fixedViewBox = stableFrame ? baseModel?.viewBox : undefined
  React.useEffect(() => {
    if (!onReport || !baseModel) return
    onReport({
      rotation,
      elevation: elevation ?? ISOMETRIC_ELEVATION,
      depth: baseModel.depth,
      guides: guides === true ? "box" : guides,
      material,
      interactive,
      highlight,
      sound,
      soundTone,
      floor: floor === true ? "grid" : floor,
    })
  }, [
    onReport,
    baseModel,
    rotation,
    elevation,
    guides,
    material,
    interactive,
    highlight,
    sound,
    soundTone,
    floor,
  ])
  return (
    <IsometricMarkView
      {...allProps}
      {...overrides}
      fixedViewBox={fixedViewBox}
    />
  )
}

function IsometricMarkView({
  shapes,
  rotation = 0,
  depth: depthProp,
  pressedDepth,
  interactive = true,
  highlight = true,
  guides = false,
  material = "hatch",
  elevation,
  floor = false,
  sound = false,
  soundTone = 0.6,
  label,
  className,
  ref,
  onPointerDown,
  onPointerUp,
  onPointerCancel,
  onPointerLeave,
  fixedViewBox,
  ...props
}: IsometricMarkProps & { fixedViewBox?: IsometricModel["viewBox"] }) {
  const id = React.useId()
  const patternId = `isometric-mark-pattern-${id}`
  const gradientId = `isometric-mark-light-${id}`

  const model = React.useMemo(
    () =>
      buildIsometricModel(
        rotateShapes(shapes, rotation),
        depthProp,
        guides === "edges" ? "edges" : "box",
        elevation,
      ),
    [shapes, rotation, depthProp, guides, elevation],
  )
  const { unit, depth } = model
  const viewBox = fixedViewBox ?? model.viewBox
  const pressedHeight = pressedDepth ?? depth / 2

  const rootRef = React.useRef<SVGSVGElement | null>(null)
  const setRef = React.useCallback(
    (node: SVGSVGElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref],
  )

  const reduceMotion = useReducedMotion()
  const inView = useInView(rootRef, { margin: "80px" })

  // 当前挤出高度;顶面整体下移 depth - height,侧墙按 height 重算
  const [pressed, setPressed] = React.useState(false)
  const height = useSpring(depth, PRESS_SPRING)
  React.useEffect(() => {
    const target = pressed ? pressedHeight : depth
    if (reduceMotion) height.jump(target)
    else height.set(target)
  }, [pressed, pressedHeight, depth, reduceMotion, height])
  // 用 y 偏移而不是 transform 字符串,斜纹才会跟着轮廓一起走
  const topOffset = useTransform(height, (h) => (depth - h) * model.heightScale)

  // 高光:鼠标在窗口中的相对位置映射到 viewBox
  const mouseX = useMotionValue(0.5)
  const mouseY = useMotionValue(0.5)
  const lightX = useSpring(
    useTransform(mouseX, [0, 1], [viewBox.x, viewBox.x + viewBox.width]),
    LIGHT_SPRING,
  )
  const lightY = useSpring(
    useTransform(mouseY, [0, 1], [viewBox.y, viewBox.y + viewBox.height]),
    LIGHT_SPRING,
  )
  React.useEffect(() => {
    if (!highlight || reduceMotion || !inView) return
    if (window.matchMedia("(hover: none)").matches) return

    const handleMouseMove = (event: MouseEvent) => {
      mouseX.set(event.clientX / window.innerWidth)
      mouseY.set(event.clientY / window.innerHeight)
    }
    window.addEventListener("mousemove", handleMouseMove, { passive: true })
    return () => window.removeEventListener("mousemove", handleMouseMove)
  }, [highlight, reduceMotion, inView, mouseX, mouseY])

  const [muted] = useIsometricMarkMuted()
  const audible = sound && !muted
  const pressedRef = React.useRef(false)
  // 点阵地面要和站点的 bg-dots 一样按屏幕像素算(8px 间距、1px 方点),所以量出 1px 对应多少 viewBox 单位
  const [pixel, setPixel] = React.useState(unit * 2)
  React.useEffect(() => {
    const svg = rootRef.current
    if (floor !== "dots" || !svg) return
    const update = () => {
      const width = svg.getBoundingClientRect().width
      if (width > 0) setPixel(viewBox.width / width)
    }
    update()
    const observer = new ResizeObserver(update)
    observer.observe(svg)
    return () => observer.disconnect()
  }, [floor, viewBox.width])

  // 各种地面的浓淡:格点面积小要加重,棋盘格是整块填色要减轻
  const floorStrength =
    floor === "dots"
      ? 3.2
      : floor === "checker"
        ? 0.45
        : floor === "lines"
          ? 1.3
          : 1
  const release = () => {
    if (pressedRef.current && audible) playRelease(soundTone)
    pressedRef.current = false
    setPressed(false)
  }
  const hatch = unit * 10

  return (
    <svg
      ref={setRef}
      data-slot="isometric-mark"
      xmlns="http://www.w3.org/2000/svg"
      viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
      fill="none"
      role={label ? "img" : undefined}
      aria-label={label}
      aria-hidden={label ? undefined : true}
      className={cn(
        "block h-auto w-full touch-manipulation overflow-visible select-none",
        // 颜色变量:顶面、侧墙、纹理、线条、着色比例。暗色下背景接近黑色,按同样比例混色几乎看不见,
        // 所以单独提亮:顶面比背景亮一档、侧墙再暗一点形成体积,纹理与线条加重,着色比例加大
        "[--guide:color-mix(in_oklab,var(--foreground)_14%,transparent)] [--pattern:color-mix(in_oklab,var(--foreground)_12%,var(--background))] [--stroke:color-mix(in_oklab,var(--foreground)_16%,var(--background))] [--tone-line:60%] [--tone-top:16%] [--tone-wall:9%] [--top:var(--background)] [--wall:var(--background)]",
        "dark:[--guide:color-mix(in_oklab,var(--foreground)_22%,transparent)] dark:[--pattern:color-mix(in_oklab,var(--foreground)_24%,var(--background))] dark:[--stroke:color-mix(in_oklab,var(--foreground)_38%,var(--background))] dark:[--tone-line:80%] dark:[--tone-top:30%] dark:[--tone-wall:18%] dark:[--top:color-mix(in_oklab,var(--foreground)_9%,var(--background))] dark:[--wall:color-mix(in_oklab,var(--foreground)_4%,var(--background))]",
        className,
      )}
      onPointerDown={(event) => {
        onPointerDown?.(event)
        if (!interactive) return
        // 按住压下时别把旁边的文字一起选中(拖动或双击会从这里开始选区)
        if (event.pointerType === "mouse") event.preventDefault()
        if (audible) playPress(soundTone)
        pressedRef.current = true
        setPressed(true)
      }}
      onPointerUp={(event) => {
        onPointerUp?.(event)
        release()
      }}
      onPointerCancel={(event) => {
        onPointerCancel?.(event)
        release()
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event)
        release()
      }}
      {...props}
    >
      <defs>
        <pattern
          id={patternId}
          width={hatch}
          height={hatch}
          patternUnits="userSpaceOnUse"
        >
          {material === "dots" ? (
            <circle
              cx={hatch / 2}
              cy={hatch / 2}
              r={unit * 1.1}
              fill="var(--pattern)"
            />
          ) : (
            <path
              d={materialPath(material, hatch, unit)}
              stroke="var(--pattern)"
              strokeWidth={unit}
            />
          )}
        </pattern>

        <motion.radialGradient
          id={gradientId}
          cx={lightX}
          cy={lightY}
          r={unit * 200}
          gradientUnits="userSpaceOnUse"
        >
          <stop stopColor="var(--foreground)" stopOpacity="0.7" />
          <stop offset="1" stopColor="var(--foreground)" stopOpacity="0" />
        </motion.radialGradient>
      </defs>

      {floor ? (
        <>
          {/* 地面网格直接用径向渐变描边:中心清晰、向外淡出,不影响图形本身 */}
          <radialGradient
            id={`${patternId}-floor`}
            cx={viewBox.x + viewBox.width / 2}
            cy={viewBox.y + viewBox.height / 2}
            r={Math.max(viewBox.width, viewBox.height) * 0.7}
            gradientUnits="userSpaceOnUse"
          >
            <stop
              offset="0"
              style={{
                stopColor: "var(--foreground)",
                stopOpacity: 0.1 * floorStrength,
              }}
            />
            <stop
              offset="0.55"
              style={{
                stopColor: "var(--foreground)",
                stopOpacity: 0.07 * floorStrength,
              }}
            />
            <stop
              offset="1"
              style={{ stopColor: "var(--foreground)", stopOpacity: 0 }}
            />
          </radialGradient>
          {floor === "dots" ? (
            // 格点地面用站点的点阵底纹:8px 间距、1px 方点、前景色 34%(同 grid.css 的 bg-dots),
            // 铺在屏幕平面上,用径向渐变遮罩向四周淡出
            <>
              <pattern
                id={`${patternId}-dots`}
                width={pixel * 8}
                height={pixel * 8}
                patternUnits="userSpaceOnUse"
              >
                <rect
                  x={pixel * 4}
                  y={pixel * 4}
                  width={pixel}
                  height={pixel}
                  fill="color-mix(in oklab, var(--foreground) 34%, transparent)"
                />
              </pattern>
              <mask
                id={`${patternId}-dots-mask`}
                maskUnits="userSpaceOnUse"
                x={viewBox.x - viewBox.width * 2}
                y={viewBox.y - viewBox.height * 2}
                width={viewBox.width * 5}
                height={viewBox.height * 5}
              >
                <rect
                  x={viewBox.x - viewBox.width * 2}
                  y={viewBox.y - viewBox.height * 2}
                  width={viewBox.width * 5}
                  height={viewBox.height * 5}
                  fill={`url(#${patternId}-dots-fade)`}
                />
              </mask>
              <radialGradient
                id={`${patternId}-dots-fade`}
                cx={viewBox.x + viewBox.width / 2}
                cy={viewBox.y + viewBox.height / 2}
                r={Math.max(viewBox.width, viewBox.height) * 1.5}
                gradientUnits="userSpaceOnUse"
              >
                <stop offset="0.3" stopColor="white" />
                <stop offset="1" stopColor="white" stopOpacity="0" />
              </radialGradient>
              <rect
                data-slot="isometric-mark-floor"
                data-floor="dots"
                x={viewBox.x - viewBox.width * 2}
                y={viewBox.y - viewBox.height * 2}
                width={viewBox.width * 5}
                height={viewBox.height * 5}
                fill={`url(#${patternId}-dots)`}
                mask={`url(#${patternId}-dots-mask)`}
              />
            </>
          ) : floor === "checker" ? (
            <path
              data-slot="isometric-mark-floor"
              data-floor="checker"
              d={model.floorCells}
              fill={`url(#${patternId}-floor)`}
            />
          ) : (
            <path
              data-slot="isometric-mark-floor"
              data-floor={floor === "lines" ? "lines" : "grid"}
              d={floor === "lines" ? model.floorLines : model.floor}
              stroke={`url(#${patternId}-floor)`}
              strokeWidth={floor === "lines" ? unit * 1.5 : unit}
            />
          )}
        </>
      ) : null}

      {guides ? (
        <path
          data-slot="isometric-mark-guides"
          d={model.guides}
          stroke="var(--guide)"
          strokeWidth={unit}
          strokeDasharray={`${unit * 4} ${unit * 2}`}
        />
      ) : null}

      <g
        strokeWidth={unit}
        strokeLinejoin="round"
        strokeLinecap="round"
        fillRule="evenodd"
      >
        {model.walls.map((wall, index) => (
          <IsometricMarkWall
            key={index}
            wall={wall}
            height={height}
            gradientId={gradientId}
            heightScale={model.heightScale}
          />
        ))}

        <motion.g data-slot="isometric-mark-top" style={{ y: topOffset }}>
          {model.tops.some((top) => top.tone) ? (
            // 有色调的形状逐块画:淡色底 + 同一张材质纹理 + 同色描边
            model.tops.map((top, index) => (
              <g key={index}>
                <path
                  d={top.d}
                  fill={
                    top.tone
                      ? `color-mix(in oklab, ${top.tone} var(--tone-top), var(--top))`
                      : "var(--top)"
                  }
                />
                <path d={top.d} fill={`url(#${patternId})`} />
                <path
                  d={top.d}
                  stroke={
                    top.tone
                      ? `color-mix(in oklab, ${top.tone} var(--tone-line), var(--background))`
                      : "var(--stroke)"
                  }
                />
              </g>
            ))
          ) : (
            <>
              <path d={model.top} fill="var(--top)" />
              <path d={model.top} fill={`url(#${patternId})`} />
              <path d={model.top} stroke="var(--stroke)" />
            </>
          )}
          <path d={model.top} stroke={`url(#${gradientId})`} />
        </motion.g>
      </g>
    </svg>
  )
}

export { IsometricMark, IsometricMarkTuningContext, useIsometricMarkMuted }
export type {
  IsometricMarkProps,
  IsometricMarkReport,
  IsometricMarkTuning,
  IsometricFloor,
  IsometricMaterial,
  IsometricPoint,
  IsometricShape,
}
components/ui/isometric-mark/geometry.ts
/**
 * 等轴测挤出的纯几何计算:只依赖数学,不碰 React,方便单测与复用。
 *
 * 坐标约定:平面 logo 的坐标 (x, y),y 向下;挤出方向为 z(向上)。
 * 投影:X = (x - y)·cos30°,Y = (x + y)·sin30° - z。
 * 在这个投影下,观察者位于 (+x, +y, +z) 一侧,朝向 (1, 1) 的墙面可见。
 */

/** 平面坐标点 `[x, y]` */
type IsometricPoint = readonly [number, number]

/** 一个平面形状:一圈外轮廓加若干个孔,孔会被挤成通孔。 */
type IsometricShape = {
  outer: readonly IsometricPoint[]
  holes?: readonly (readonly IsometricPoint[])[]
  /** 这块形状的色调(任意 CSS 颜色):顶面淡淡着色、线条取同色;不传为单色线稿 */
  tone?: string
}

/** 墙面底边在屏幕上的两个端点(z = 0),顶边由当前高度决定。 */
type IsometricWall = {
  x0: number
  y0: number
  x1: number
  y1: number
  tone?: string
}

type IsometricModel = {
  viewBox: { x: number; y: number; width: number; height: number }
  /** 线宽、斜纹、虚线等“一个像素”对应的 viewBox 长度,随图形尺寸等比缩放 */
  unit: number
  /** 实际使用的挤出高度(props 未传时为自动值) */
  depth: number
  /** 顶面路径(z = depth),外轮廓与孔都在其中,需配合 evenodd */
  top: string
  /** 按形状拆开的顶面,带各自的色调 */
  tops: { d: string; tone?: string }[]
  /** 可见侧墙,按从远到近排好序 */
  walls: IsometricWall[]
  /** 背景辅助线路径(z = 0) */
  guides: string
  /** 地面网格路径(z = 0),与辅助线同方向 */
  floor: string
  /** 只有第一个方向的地面横线 */
  floorLines: string
  /** 地面格点(两组格线的交点) */
  floorDots: string
  /** 棋盘格:隔一格填一格 */
  floorCells: string
  /** 挤出高度 1 在屏幕上对应的竖直长度(随俯仰角变化,标准等轴测为 1) */
  heightScale: number
}

const COS30 = Math.cos(Math.PI / 6)
const SIN30 = 0.5
const EPSILON = 1e-6
/** 自动挤出高度占图形平面尺寸的比例 */
const AUTO_DEPTH_RATIO = 0.12

const fmt = (n: number) => String(Math.round(n * 100) / 100)

/** 标准等轴测的俯仰角:视线与地面成 arctan(1/√2) ≈ 35.26° */
const ISOMETRIC_ELEVATION = (Math.atan(1 / Math.SQRT2) * 180) / Math.PI

/**
 * 投影系数:水平方位固定 45°(两条地面轴左右对称),俯仰角可调。
 * 地面方向的竖直压缩 ky = sinE·√2·cos30°,高度方向 kz = cosE·√2·cos30°;
 * 俯仰角取 35.26° 时 ky = 0.5、kz = 1,正好是标准等轴测。
 * 俯仰角越大越接近俯视(地面越“方”、厚度越薄),越小越接近平视(地面越扁、厚度越高)。
 */
let KY = SIN30
let KZ = 1

function setElevation(degrees: number) {
  const e = (Math.min(Math.max(degrees, 5), 85) * Math.PI) / 180
  KY = Math.sin(e) * Math.SQRT2 * COS30
  KZ = Math.cos(e) * Math.SQRT2 * COS30
}

function project(x: number, y: number, z = 0): [number, number] {
  return [(x - y) * COS30, (x + y) * KY - z * KZ]
}

/** 去掉首尾重复的闭合点,统一成不含重复端点的环。 */
function cleanRing(ring: readonly IsometricPoint[]): IsometricPoint[] {
  const points = [...ring]
  const first = points[0]
  const last = points[points.length - 1]
  if (points.length > 1 && first[0] === last[0] && first[1] === last[1]) {
    points.pop()
  }
  return points
}

function signedArea(ring: readonly IsometricPoint[]) {
  let sum = 0
  for (let i = 0; i < ring.length; i++) {
    const [x0, y0] = ring[i]
    const [x1, y1] = ring[(i + 1) % ring.length]
    sum += x0 * y1 - x1 * y0
  }
  return sum / 2
}

/** 外轮廓取正面积、孔取负面积,这样所有环的“实体外侧”法线公式一致。 */
function orient(ring: IsometricPoint[], positive: boolean) {
  return signedArea(ring) > 0 === positive ? ring : [...ring].reverse()
}

function ringPath(ring: readonly IsometricPoint[], z: number) {
  return (
    ring
      .map(([x, y], i) => {
        const [px, py] = project(x, y, z)
        return `${i === 0 ? "M" : "L"}${fmt(px)} ${fmt(py)}`
      })
      .join("") + "Z"
  )
}

type Segment = {
  /** 平面坐标,用于排序 */
  px: number
  py: number
  qx: number
  qy: number
  tone?: string
}

/** 排序键:沿观察方向的“深度”,越大越靠近观察者。 */
const depthOf = (s: Segment) => (s.px + s.py + s.qx + s.qy) / 4

/**
 * 判断两面墙的前后关系:-1 表示 a 在 b 后面(先画 a),1 相反,0 表示互不遮挡。
 * 墙在屏幕上只有 X = x - y 方向的范围,范围不重叠就不会互相遮挡;
 * 重叠区间内两线段不相交,因此取区间中点比较 x + y 即可。
 */
function compareSegments(a: Segment, b: Segment) {
  const ua0 = a.px - a.py
  const ua1 = a.qx - a.qy
  const ub0 = b.px - b.py
  const ub1 = b.qx - b.qy
  const lo = Math.max(Math.min(ua0, ua1), Math.min(ub0, ub1))
  const hi = Math.min(Math.max(ua0, ua1), Math.max(ub0, ub1))
  if (hi - lo < EPSILON) return 0

  const u = (lo + hi) / 2
  const depthAt = (s: Segment, u0: number, u1: number) => {
    const t = (u - u0) / (u1 - u0)
    return s.px + s.py + t * (s.qx + s.qy - s.px - s.py)
  }
  const diff = depthAt(a, ua0, ua1) - depthAt(b, ub0, ub1)
  if (Math.abs(diff) < EPSILON) return 0
  return diff < 0 ? -1 : 1
}

/**
 * 画家算法的顺序:先按“谁挡住谁”做拓扑排序,凹多边形也能排对;
 * 理论上不会出现环,真遇到时退回按深度最小者优先,保证一定有结果。
 * 顺序只取决于平面形状,与挤出高度无关,所以按下动画时不用重排。
 */
function sortBackToFront(segments: Segment[]) {
  const n = segments.length
  const after: number[][] = Array.from({ length: n }, () => [])
  const blockers = new Array<number>(n).fill(0)

  for (let i = 0; i < n; i++) {
    for (let j = i + 1; j < n; j++) {
      const order = compareSegments(segments[i], segments[j])
      if (order < 0) {
        after[i].push(j)
        blockers[j]++
      } else if (order > 0) {
        after[j].push(i)
        blockers[i]++
      }
    }
  }

  const done = new Array<boolean>(n).fill(false)
  const sorted: Segment[] = []
  for (let k = 0; k < n; k++) {
    let pick = -1
    for (let i = 0; i < n; i++) {
      if (done[i] || blockers[i] > 0) continue
      if (pick < 0 || depthOf(segments[i]) < depthOf(segments[pick])) pick = i
    }
    if (pick < 0) {
      for (let i = 0; i < n; i++) {
        if (done[i]) continue
        if (pick < 0 || depthOf(segments[i]) < depthOf(segments[pick])) pick = i
      }
    }
    done[pick] = true
    sorted.push(segments[pick])
    for (const next of after[pick]) blockers[next]--
  }
  return sorted
}

function buildIsometricModel(
  shapes: readonly IsometricShape[],
  depthOption?: number,
  guideMode: "box" | "edges" = "box",
  elevation: number = ISOMETRIC_ELEVATION,
): IsometricModel {
  setElevation(elevation)
  const rings: {
    ring: IsometricPoint[]
    hole: boolean
    shape: number
    tone?: string
  }[] = []
  shapes.forEach((shape, index) => {
    const tone = shape.tone
    rings.push({
      ring: orient(cleanRing(shape.outer), true),
      hole: false,
      shape: index,
      tone,
    })
    for (const hole of shape.holes ?? []) {
      rings.push({
        ring: orient(cleanRing(hole), false),
        hole: true,
        shape: index,
        tone,
      })
    }
  })

  // 平面包围盒,用于自动高度与辅助线
  let minX = Infinity
  let minY = Infinity
  let maxX = -Infinity
  let maxY = -Infinity
  for (const { ring } of rings) {
    for (const [x, y] of ring) {
      minX = Math.min(minX, x)
      minY = Math.min(minY, y)
      maxX = Math.max(maxX, x)
      maxY = Math.max(maxY, y)
    }
  }
  if (!rings.length) {
    minX = minY = maxX = maxY = 0
  }
  const planSize = Math.max(maxX - minX, maxY - minY, 1)
  const depth = Math.max(depthOption ?? planSize * AUTO_DEPTH_RATIO, 0)

  // 背面剔除:法线 (ey, -ex) 与观察方向 (1, 1) 同侧才可见;
  // 法线与观察方向垂直的墙在屏幕上是一条竖线,面积为零,也不画。
  const segments: Segment[] = []
  for (const { ring, tone } of rings) {
    for (let i = 0; i < ring.length; i++) {
      const [px, py] = ring[i]
      const [qx, qy] = ring[(i + 1) % ring.length]
      if (qy - py - (qx - px) > EPSILON) segments.push({ px, py, qx, qy, tone })
    }
  }
  const walls = sortBackToFront(segments).map((s) => {
    const [x0, y0] = project(s.px, s.py)
    const [x1, y1] = project(s.qx, s.qy)
    return { x0, y0, x1, y1, tone: s.tone }
  })

  // 投影后的包围盒:顶面在 z = depth(最高),底边在 z = 0(最低)
  let bx0 = Infinity
  let by0 = Infinity
  let bx1 = -Infinity
  let by1 = -Infinity
  for (const { ring } of rings) {
    for (const [x, y] of ring) {
      const [px, py] = project(x, y)
      bx0 = Math.min(bx0, px)
      bx1 = Math.max(bx1, px)
      by0 = Math.min(by0, py - depth * KZ)
      by1 = Math.max(by1, py)
    }
  }
  if (!rings.length) {
    bx0 = by0 = bx1 = by1 = 0
  }
  const size = Math.max(bx1 - bx0, by1 - by0, 1)
  const margin = size * 0.02

  // 辅助线:
  // - "box":包围盒四条边沿两条等轴测轴延长(横平竖直的图形最合适);
  // - "edges":沿图形自己的主方向——按边长统计各方向,取最长的两个,每个方向画两条外侧切线并延长。
  //   斜向的图形(如一组斜杠)辅助线会贴着它的长边与齐平的端点。
  const reach = planSize * 1.2
  const line = (x0: number, y0: number, x1: number, y1: number) => {
    const [ax, ay] = project(x0, y0)
    const [bx, by] = project(x1, y1)
    return `M${fmt(ax)} ${fmt(ay)}L${fmt(bx)} ${fmt(by)}`
  }
  const weights = new Map<number, { ux: number; uy: number; length: number }>()
  for (const { ring } of rings) {
    for (let i = 0; i < ring.length; i++) {
      const [px, py] = ring[i]
      const [qx, qy] = ring[(i + 1) % ring.length]
      const length = Math.hypot(qx - px, qy - py)
      if (length < EPSILON) continue
      // 方向取 [0°, 180°),按 0.5° 分桶,避免浮点误差把同一方向拆开
      let angle = (Math.atan2(qy - py, qx - px) * 180) / Math.PI
      angle = ((angle % 180) + 180) % 180
      const key = Math.round(angle * 2) % 360
      const entry = weights.get(key)
      if (entry) entry.length += length
      else {
        const r = (key / 2) * (Math.PI / 180)
        weights.set(key, { ux: Math.cos(r), uy: Math.sin(r), length })
      }
    }
  }
  const directions =
    guideMode === "edges"
      ? [...weights.values()].sort((a, b) => b.length - a.length).slice(0, 2)
      : [
          { ux: 1, uy: 0, length: 0 },
          { ux: 0, uy: 1, length: 0 },
        ]
  const points = rings.flatMap(({ ring }) => ring)
  // 地面网格:与辅助线同两个方向、同一个平面(z = 0),目标是图形的每条边都落在格线上。
  // 每个方向单独算:所有顶点在法线方向上的距离若有足够大的公约数(如标志左端切口
  // 0 / 78 / 156 / 416 的公约数 26),就按它等距铺;否则每条边各画一条线,外侧按平均间距延伸。
  const floorTarget = planSize / 10
  const floorReach = planSize * 0.9
  const approxGcd = (a: number, b: number): number => {
    const tolerance = planSize / 400
    while (b > tolerance) [a, b] = [b, a % b]
    return a
  }
  // 每个方向算出一组格线位置(法线方向上的绝对偏移),地面的各种材质都由这两组格线组合而成
  const families = directions.map(({ ux, uy }) => {
    const nx = -uy
    const ny = ux
    let lo = Infinity
    let hi = -Infinity
    let a0 = Infinity
    let a1 = -Infinity
    for (const [x, y] of points) {
      lo = Math.min(lo, x * nx + y * ny)
      hi = Math.max(hi, x * nx + y * ny)
      a0 = Math.min(a0, x * ux + y * uy)
      a1 = Math.max(a1, x * ux + y * uy)
    }
    const span = hi - lo
    const offsets = [...new Set(points.map(([x, y]) => x * nx + y * ny - lo))]
      .sort((p, q) => p - q)
      .filter((d, i, all) => i === 0 || d - all[i - 1] > planSize / 400)
    let g = 0
    for (const d of offsets) g = approxGcd(g, d)
    let ds: number[]
    let step: number
    if (span > EPSILON && g >= span / 24) {
      // 所有边的间距有公约数:按公约数等距铺,图形里的每条边都落在格线上;
      // 公约数太大(格子太稀)时再等分,等分后边依然在格线上
      step = g / Math.max(1, Math.round(g / floorTarget))
      ds = []
      for (let d = 0; d <= span + EPSILON; d += step) ds.push(d)
    } else {
      // 没有合适的公约数:每条边各画一条格线(像构造图那样),外侧按平均间距延伸
      ds = offsets
      step = offsets.length > 1 ? span / (offsets.length - 1) : floorTarget
    }
    const all = [...ds]
    for (let d = -step; d >= -floorReach; d -= step) all.push(d)
    for (let d = span + step; d <= span + floorReach; d += step) all.push(d)
    all.sort((p, q) => p - q)
    return {
      ux,
      uy,
      nx,
      ny,
      offsets: all.map((d) => lo + d),
      t0: a0 - floorReach,
      t1: a1 + floorReach,
    }
  })
  const lineOf = (f: (typeof families)[number], off: number) =>
    line(
      off * f.nx + f.t0 * f.ux,
      off * f.ny + f.t0 * f.uy,
      off * f.nx + f.t1 * f.ux,
      off * f.ny + f.t1 * f.uy,
    )
  const floor = families
    .flatMap((f) => f.offsets.map((off) => lineOf(f, off)))
    .join("")
  const floorLines = families[0]
    ? families[0].offsets.map((off) => lineOf(families[0], off)).join("")
    : ""
  // 两组格线的交点:解 n1·p = d1、n2·p = d2
  const cross = (d1: number, d2: number): [number, number] | null => {
    const [f1, f2] = families
    const det = f1.nx * f2.ny - f1.ny * f2.nx
    if (Math.abs(det) < EPSILON) return null
    return [(d1 * f2.ny - f1.ny * d2) / det, (f1.nx * d2 - d1 * f2.nx) / det]
  }
  let floorDots = ""
  let floorCells = ""
  if (families.length === 2) {
    const [f1, f2] = families
    const r = planSize / 130
    f1.offsets.forEach((d1) => {
      f2.offsets.forEach((d2) => {
        const p = cross(d1, d2)
        if (!p) return
        const [x, y] = project(p[0], p[1])
        floorDots += `M${fmt(x - r)} ${fmt(y)}a${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(r * 2)} 0a${fmt(r)} ${fmt(r)} 0 1 0 ${fmt(-r * 2)} 0`
      })
    })
    for (let i = 0; i < f1.offsets.length - 1; i++) {
      for (let j = 0; j < f2.offsets.length - 1; j++) {
        if ((i + j) % 2) continue
        const corners = [
          cross(f1.offsets[i], f2.offsets[j]),
          cross(f1.offsets[i + 1], f2.offsets[j]),
          cross(f1.offsets[i + 1], f2.offsets[j + 1]),
          cross(f1.offsets[i], f2.offsets[j + 1]),
        ]
        if (corners.some((c) => !c)) continue
        floorCells +=
          corners
            .map((c, k) => {
              const [x, y] = project(c![0], c![1])
              return `${k ? "L" : "M"}${fmt(x)} ${fmt(y)}`
            })
            .join("") + "Z"
      }
    }
  }

  const guides = directions
    .flatMap(({ ux, uy }) => {
      const nx = -uy
      const ny = ux
      let lo = Infinity
      let hi = -Infinity
      let a0 = Infinity
      let a1 = -Infinity
      for (const [x, y] of points) {
        const d = x * nx + y * ny
        const t = x * ux + y * uy
        lo = Math.min(lo, d)
        hi = Math.max(hi, d)
        a0 = Math.min(a0, t)
        a1 = Math.max(a1, t)
      }
      return [lo, hi].map((d) => {
        const t0 = a0 - reach
        const t1 = a1 + reach
        return line(
          d * nx + t0 * ux,
          d * ny + t0 * uy,
          d * nx + t1 * ux,
          d * ny + t1 * uy,
        )
      })
    })
    .join("")

  const viewBox = {
    x: bx0 - margin,
    y: by0 - margin,
    width: bx1 - bx0 + margin * 2,
    height: by1 - by0 + margin * 2,
  }
  return {
    viewBox,
    unit: size / 500,
    depth,
    top: rings.map(({ ring }) => ringPath(ring, depth)).join(""),
    tops: shapes.map((shape, index) => ({
      d: rings
        .filter((r) => r.shape === index)
        .map(({ ring }) => ringPath(ring, depth))
        .join(""),
      tone: shape.tone,
    })),
    walls,
    guides,
    floor,
    floorLines,
    floorDots,
    floorCells,
    heightScale: KZ,
  }
}

/** 高度为 h 时一面墙的路径:底边固定在 z = 0,顶边抬到 z = h。 */
function wallPath(
  { x0, y0, x1, y1 }: IsometricWall,
  height: number,
  heightScale = 1,
) {
  const h = height * heightScale
  return `M${fmt(x0)} ${fmt(y0 - h)}L${fmt(x1)} ${fmt(y1 - h)}L${fmt(x1)} ${fmt(y1)}L${fmt(x0)} ${fmt(y0)}Z`
}

/**
 * 投影前把平面形状绕包围盒中心旋转 `degrees` 度(y 向下坐标系里顺时针为正)。
 * 建议取 90 的整数倍:这样图形的横竖边始终沿等轴测斜轴,仍是一块平放的板;
 * 其他角度(如 -45°)会让顶面看起来像一堵立着的墙。
 */
function rotateShapes(
  shapes: readonly IsometricShape[],
  degrees: number,
): IsometricShape[] {
  if (!degrees) return shapes.map((s) => ({ ...s }))
  const all = shapes.flatMap((s) => [...s.outer, ...(s.holes ?? []).flat()])
  const xs = all.map(([x]) => x)
  const ys = all.map(([, y]) => y)
  const cx = (Math.min(...xs) + Math.max(...xs)) / 2
  const cy = (Math.min(...ys) + Math.max(...ys)) / 2
  const r = (degrees * Math.PI) / 180
  const cos = Math.cos(r)
  const sin = Math.sin(r)
  const turn = (ring: readonly IsometricPoint[]): IsometricPoint[] =>
    ring.map(([x, y]) => [
      cx + (x - cx) * cos - (y - cy) * sin,
      cy + (x - cx) * sin + (y - cy) * cos,
    ])
  return shapes.map((s) => ({
    outer: turn(s.outer),
    holes: s.holes?.map(turn),
    tone: s.tone,
  }))
}

export { buildIsometricModel, ISOMETRIC_ELEVATION, rotateShapes, wallPath }
export type { IsometricModel, IsometricPoint, IsometricShape, IsometricWall }
components/ui/isometric-mark/sound.ts
"use client"

import * as React from "react"

/**
 * 按下 / 松开的音效:用 Web Audio 现场合成,不需要音频文件。
 * 按下是一声清脆的「咔」(短促的下滑正弦 + 高频带通噪声做触感,只留少量低频厚度),松开是更轻、更高的一声「嗒」。
 * AudioContext 在第一次按下(用户手势)时才创建,满足浏览器的自动播放限制。
 */
let context: AudioContext | null = null

function audio() {
  if (typeof window === "undefined") return null
  if (!context) {
    const Ctor =
      window.AudioContext ??
      (window as unknown as { webkitAudioContext?: typeof AudioContext })
        .webkitAudioContext
    if (!Ctor) return null
    context = new Ctor()
  }
  if (context.state === "suspended") void context.resume()
  return context
}

function tone(from: number, to: number, duration: number, volume: number) {
  const ctx = audio()
  if (!ctx) return
  const now = ctx.currentTime
  const osc = ctx.createOscillator()
  const gain = ctx.createGain()
  osc.type = "sine"
  osc.frequency.setValueAtTime(from, now)
  osc.frequency.exponentialRampToValueAtTime(to, now + duration)
  gain.gain.setValueAtTime(0.0001, now)
  gain.gain.exponentialRampToValueAtTime(volume, now + 0.004)
  gain.gain.exponentialRampToValueAtTime(0.0001, now + duration)
  osc.connect(gain).connect(ctx.destination)
  osc.start(now)
  osc.stop(now + duration + 0.02)
}

function tick(frequency: number, duration: number, volume: number) {
  const ctx = audio()
  if (!ctx) return
  const now = ctx.currentTime
  const length = Math.ceil(ctx.sampleRate * duration)
  const buffer = ctx.createBuffer(1, length, ctx.sampleRate)
  const data = buffer.getChannelData(0)
  for (let i = 0; i < length; i++)
    data[i] = (Math.random() * 2 - 1) * (1 - i / length) ** 3
  const source = ctx.createBufferSource()
  source.buffer = buffer
  const filter = ctx.createBiquadFilter()
  filter.type = "bandpass"
  filter.frequency.value = frequency
  filter.Q.value = 1.2
  const gain = ctx.createGain()
  gain.gain.value = volume
  source.connect(filter).connect(gain).connect(ctx.destination)
  source.start(now)
}

const mix = (a: number, b: number, t: number) => a + (b - a) * t

/**
 * 音色在「闷」与「脆」之间连续插值:0 是低沉的「咚」(长低频、暗噪声),
 * 1 是清脆的「咔」(短低频、加一层高频短音、亮噪声)。默认 0.6。
 */
function playPress(crisp = 0.6) {
  const t = Math.min(Math.max(crisp, 0), 1)
  tone(
    mix(150, 340, t),
    mix(62, 150, t),
    mix(0.14, 0.06, t),
    mix(0.22, 0.12, t),
  )
  if (t > 0.05) tone(mix(900, 1600, t), mix(700, 1000, t), 0.03, 0.08 * t)
  tick(mix(1600, 4200, t), mix(0.022, 0.013, t), mix(0.1, 0.22, t))
}

function playRelease(crisp = 0.6) {
  const t = Math.min(Math.max(crisp, 0), 1)
  tone(
    mix(260, 700, t),
    mix(180, 520, t),
    mix(0.06, 0.035, t),
    mix(0.08, 0.06, t),
  )
  tick(mix(3000, 6000, t), mix(0.012, 0.009, t), mix(0.05, 0.13, t))
}

/* ---------- 静音偏好:存在 localStorage,全站带音效的组件共用 ---------- */

const STORAGE_KEY = "travinsy:sound:muted"
/** 旧版本用过的键:读到新键没写过时按它迁移,保证升级后偏好不丢。 */
const LEGACY_KEYS = ["isometric-mark:muted"]
const WATCHED_KEYS = [STORAGE_KEY, ...LEGACY_KEYS]
/**
 * 同页广播:`storage` 事件只发给「其他」标签页,本页写 localStorage 不会触发它。
 * 带音效的组件各自有一份静音模块(互不 import),所以约定用同一个事件名互相通知。
 */
const MUTED_EVENT = "travinsy:sound-muted-change"
const listeners = new Set<() => void>()

function readMuted() {
  try {
    const current = window.localStorage.getItem(STORAGE_KEY)
    if (current !== null) return current === "1"
    return LEGACY_KEYS.some((key) => window.localStorage.getItem(key) === "1")
  } catch {
    return false
  }
}

function setMuted(muted: boolean) {
  try {
    window.localStorage.setItem(STORAGE_KEY, muted ? "1" : "0")
  } catch {
    // 隐私模式等存不了时只在本页生效
  }
  listeners.forEach((listener) => listener())
  // 通知同页其他带音效的组件
  window.dispatchEvent(new Event(MUTED_EVENT))
}

function subscribe(listener: () => void) {
  listeners.add(listener)
  const notify = () => listener()
  const onStorage = (event: StorageEvent) => {
    if (event.key === null || WATCHED_KEYS.includes(event.key)) notify()
  }
  window.addEventListener("storage", onStorage)
  window.addEventListener(MUTED_EVENT, notify)
  return () => {
    listeners.delete(listener)
    window.removeEventListener("storage", onStorage)
    window.removeEventListener(MUTED_EVENT, notify)
  }
}

/** 读取 / 切换音效静音。服务端与水合阶段按未静音渲染,挂载后再读本地偏好。 */
function useIsometricMarkMuted() {
  const muted = React.useSyncExternalStore(subscribe, readMuted, () => false)
  return [muted, setMuted] as const
}

export { playPress, playRelease, useIsometricMarkMuted }