(function (root, factory) {
  const api = factory(root);
  if (typeof module === "object" && module.exports) module.exports = api;
  root.JianglaiDieline3D = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function (root) {
  "use strict";

  const VERSION = "1.0.0";
  const EPSILON = 1e-8;
  const PANEL_IDS = new Set(["front", "back", "left", "right", "top", "bottom", "outside", "inside", "all"]);

  const clamp = (value, minimum, maximum) => Math.min(maximum, Math.max(minimum, Number(value)));
  const finite = (value, fallback = 0) => Number.isFinite(Number(value)) ? Number(value) : fallback;
  const lerp = (first, second, amount) => first + (second - first) * amount;
  const easeFold = value => {
    const progress = clamp(value, 0, 1);
    return progress * progress * (3 - 2 * progress);
  };

  function vectorLength(vector) {
    return Math.hypot(vector[0], vector[1], vector[2]);
  }

  function normalizeVector(vector) {
    const length = vectorLength(vector) || 1;
    return vector.map(value => value / length);
  }

  function cross(first, second) {
    return [
      first[1] * second[2] - first[2] * second[1],
      first[2] * second[0] - first[0] * second[2],
      first[0] * second[1] - first[1] * second[0]
    ];
  }

  function subtract(first, second) {
    return [first[0] - second[0], first[1] - second[1], first[2] - second[2]];
  }

  function quaternionFromEuler(x, y, z) {
    const cx = Math.cos(x / 2), sx = Math.sin(x / 2);
    const cy = Math.cos(y / 2), sy = Math.sin(y / 2);
    const cz = Math.cos(z / 2), sz = Math.sin(z / 2);
    return [
      sx * cy * cz - cx * sy * sz,
      cx * sy * cz + sx * cy * sz,
      cx * cy * sz - sx * sy * cz,
      cx * cy * cz + sx * sy * sz
    ];
  }

  function normalizeQuaternion(quaternion) {
    const length = Math.hypot(...quaternion) || 1;
    return quaternion.map(value => value / length);
  }

  function slerpQuaternion(first, second, amount) {
    let end = second.slice();
    let cosine = first.reduce((sum, value, index) => sum + value * end[index], 0);
    if (cosine < 0) {
      cosine = -cosine;
      end = end.map(value => -value);
    }
    if (cosine > .9995) {
      return normalizeQuaternion(first.map((value, index) => lerp(value, end[index], amount)));
    }
    const angle = Math.acos(clamp(cosine, -1, 1));
    const denominator = Math.sin(angle) || 1;
    const firstWeight = Math.sin((1 - amount) * angle) / denominator;
    const secondWeight = Math.sin(amount * angle) / denominator;
    return first.map((value, index) => value * firstWeight + end[index] * secondWeight);
  }

  function rotateByQuaternion(vector, quaternion) {
    const [x, y, z, w] = quaternion;
    const [vx, vy, vz] = vector;
    const ix = w * vx + y * vz - z * vy;
    const iy = w * vy + z * vx - x * vz;
    const iz = w * vz + x * vy - y * vx;
    const iw = -x * vx - y * vy - z * vz;
    return [
      ix * w + iw * -x + iy * -z - iz * -y,
      iy * w + iw * -y + iz * -x - ix * -z,
      iz * w + iw * -z + ix * -y - iy * -x
    ];
  }

  function positiveDimension(value, label) {
    const number = Number(value);
    if (!Number.isFinite(number) || number <= 0) throw new TypeError(`${label}必须是大于 0 的有限数字`);
    if (number > 3000) throw new RangeError(`${label}不能超过 3000mm`);
    return number;
  }

  function readDielineDimensions(dieline) {
    const options = dieline?.options || dieline || {};
    const definition = options.definition || {};
    const manufacturing = dieline?.manufacturing || {};
    const length = positiveDimension(
      options.length ?? options.lengthMm ?? manufacturing.length,
      "成品长度"
    );
    const width = positiveDimension(
      options.width ?? options.widthMm ?? manufacturing.width,
      "成品宽度"
    );
    const height = positiveDimension(
      options.height ?? options.heightMm ?? manufacturing.height,
      "成品高度"
    );
    return {
      length,
      width,
      height,
      family:String(definition.family || options.family || "folding-carton"),
      type:String(definition.id || options.type || "custom"),
      name:String(definition.name || options.name || "参数化包装"),
      code:String(definition.code || options.code || ""),
      material:String(options.material || "纸张"),
      thickness:Math.max(.05, finite(options.thickness ?? options.thicknessMm, .38)),
      source:dieline
    };
  }

  function panel(id, label, width, height, flatPosition, foldedPosition, foldedEuler, options = {}) {
    return {
      id,
      label,
      width,
      height,
      localPoints:Array.isArray(options.localPoints)
        ? options.localPoints.map(point => [finite(point[0]), finite(point[1]), finite(point[2])])
        : null,
      foldedPoints:Array.isArray(options.foldedPoints)
        ? options.foldedPoints.map(point => [finite(point[0]), finite(point[1]), finite(point[2])])
        : null,
      flatPosition:flatPosition.slice(),
      foldedPosition:foldedPosition.slice(),
      flatRotation:[0, 0, 0, 1],
      foldedRotation:quaternionFromEuler(...foldedEuler),
      artworkSide:options.artworkSide || id,
      optional:Boolean(options.optional),
      previewRole:String(options.previewRole || "structure"),
      featureColor:options.featureColor ? String(options.featureColor) : ""
    };
  }

  function sourceNetPreviewPanels(dimensions) {
    const topology = dimensions.source?.structureMeta?.netTopology;
    const polygons = Array.isArray(topology?.facePolygons)
      ? topology.facePolygons
        .map(polygon => polygon.map(point => [finite(point.x), -finite(point.y), 0]))
        .filter(polygon => polygon.length >= 3)
      : [];
    if (!polygons.length) return [];
    const bounds = pointsBounds2D(polygons.flat());
    const center = boundsCenter2D(bounds);
    return polygons.map((polygon, index) => {
      const centered = polygon.map(point => [point[0] - center[0], point[1] - center[1], 0]);
      const faceBounds = pointsBounds2D(centered);
      const faceCenter = boundsCenter2D(faceBounds);
      const localPoints = centered.map(point => [
        point[0] - faceCenter[0],
        point[1] - faceCenter[1],
        0
      ]);
      return panel(
        `source-net-${index + 1}`,
        `生产刀板面 ${index + 1}`,
        Math.max(EPSILON, faceBounds.maxX - faceBounds.minX),
        Math.max(EPSILON, faceBounds.maxY - faceBounds.minY),
        [faceCenter[0], faceCenter[1], 0],
        [faceCenter[0], faceCenter[1], 0],
        [0, 0, 0],
        {
          localPoints,
          artworkSide:"outside",
          previewRole:"source-net"
        }
      );
    });
  }

  function panelVisibility(face, foldProgress, hasSourceNet = true) {
    const progress = clamp(foldProgress, 0, 1);
    if (face?.previewRole === "source-net") return clamp(1 - progress / .16, 0, 1);
    if (!hasSourceNet) return 1;
    return clamp((progress - .025) / .135, 0, 1);
  }

  function gablePackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "gable" || !meta.exact3D || !Array.isArray(meta.bodyPanels) || meta.bodyPanels.length !== 4) {
      return null;
    }
    const widths = meta.bodyPanels.map((value, index) => positiveDimension(value, `第${index + 1}片盒身宽度`));
    const length = widths[0];
    const depth = widths[1];
    const height = positiveDimension(meta.bodyHeight, "盒身高度");
    const ridgeRise = positiveDimension(meta.ridgeRise, "屋脊高度");
    const roofSlope = positiveDimension(meta.roofSlope, "屋面斜长");
    const handleHeight = positiveDimension(meta.handleHeight, "提手高度");
    const handleWidth = positiveDimension(meta.handleWidth, "提手宽度");
    const bottomDepth = positiveDimension(meta.bottomDepth, "底部折翼深度");
    const total = widths.reduce((sum, value) => sum + value, 0);
    const centers = [];
    let cursor = -total / 2;
    widths.forEach(value => {
      centers.push(cursor + value / 2);
      cursor += value;
    });
    const roofAngle = Math.atan2(depth / 2, ridgeRise);
    const roofInset = length * .18;
    const roofTopHalf = Math.max(handleWidth / 2, length / 2 - roofInset);
    const frontRoofPoints = [
      [-length / 2, -roofSlope / 2, 0],
      [length / 2, -roofSlope / 2, 0],
      [roofTopHalf, roofSlope / 2, 0],
      [-roofTopHalf, roofSlope / 2, 0]
    ];
    const sideRoofPoints = [
      [-depth / 2, -ridgeRise / 2, 0],
      [depth / 2, -ridgeRise / 2, 0],
      [0, ridgeRise / 2, 0]
    ];
    const frontRoofFolded = [
      [-length / 2, height / 2, depth / 2],
      [length / 2, height / 2, depth / 2],
      [roofTopHalf, height / 2 + ridgeRise, 0],
      [-roofTopHalf, height / 2 + ridgeRise, 0]
    ];
    const backRoofFolded = [
      [-length / 2, height / 2, -depth / 2],
      [length / 2, height / 2, -depth / 2],
      [roofTopHalf, height / 2 + ridgeRise, 0],
      [-roofTopHalf, height / 2 + ridgeRise, 0]
    ];
    const rightGableFolded = [
      [length / 2, height / 2, -depth / 2],
      [length / 2, height / 2, depth / 2],
      [length / 2, height / 2 + ridgeRise, 0]
    ];
    const leftGableFolded = [
      [-length / 2, height / 2, depth / 2],
      [-length / 2, height / 2, -depth / 2],
      [-length / 2, height / 2 + ridgeRise, 0]
    ];
    const handleBottom = height / 2 + ridgeRise;
    const handleTop = handleBottom + handleHeight;
    const frontHandleFolded = [
      [-handleWidth / 2, handleTop, dimensions.thickness / 2],
      [handleWidth / 2, handleTop, dimensions.thickness / 2],
      [handleWidth / 2, handleBottom, dimensions.thickness / 2],
      [-handleWidth / 2, handleBottom, dimensions.thickness / 2]
    ];
    const backHandleFolded = frontHandleFolded.map(point => [point[0], point[1], -dimensions.thickness / 2]);
    const body = [
      panel("front", "正面盒身", length, height, [centers[0], 0, 0], [0, 0, depth / 2], [0, 0, 0]),
      panel("right", "右侧盒身", depth, height, [centers[1], 0, 0], [length / 2, 0, 0], [0, Math.PI / 2, 0]),
      panel("back", "背面盒身", length, height, [centers[2], 0, 0], [0, 0, -depth / 2], [0, Math.PI, 0]),
      panel("left", "左侧盒身", depth, height, [centers[3], 0, 0], [-length / 2, 0, 0], [0, -Math.PI / 2, 0])
    ];
    const roof = [
      panel("gable-roof-front", "正面屋面", length, roofSlope,
        [centers[0], height / 2 + roofSlope / 2, 0],
        [0, height / 2 + ridgeRise / 2, depth / 4],
        [-roofAngle, 0, 0],
        { localPoints:frontRoofPoints, foldedPoints:frontRoofFolded, artworkSide:"front" }),
      panel("gable-side-right", "右侧屋脊", depth, ridgeRise,
        [centers[1], height / 2 + ridgeRise / 2, 0],
        [length / 2, height / 2 + ridgeRise / 2, 0],
        [0, Math.PI / 2, 0],
        { localPoints:sideRoofPoints, foldedPoints:rightGableFolded, artworkSide:"right" }),
      panel("gable-roof-back", "背面屋面", length, roofSlope,
        [centers[2], height / 2 + roofSlope / 2, 0],
        [0, height / 2 + ridgeRise / 2, -depth / 4],
        [roofAngle, Math.PI, 0],
        { localPoints:frontRoofPoints, foldedPoints:backRoofFolded, artworkSide:"back" }),
      panel("gable-side-left", "左侧屋脊", depth, ridgeRise,
        [centers[3], height / 2 + ridgeRise / 2, 0],
        [-length / 2, height / 2 + ridgeRise / 2, 0],
        [0, -Math.PI / 2, 0],
        { localPoints:sideRoofPoints, foldedPoints:leftGableFolded, artworkSide:"left" })
    ];
    const handles = [
      panel("gable-handle-front", "正面提手", handleWidth, handleHeight,
        [centers[0], height / 2 + roofSlope + handleHeight / 2, 0],
        [0, height / 2 + ridgeRise + handleHeight / 2, dimensions.thickness / 2],
        [0, 0, 0],
        { foldedPoints:frontHandleFolded, artworkSide:"front" }),
      panel("gable-handle-back", "背面提手", handleWidth, handleHeight,
        [centers[2], height / 2 + roofSlope + handleHeight / 2, 0],
        [0, height / 2 + ridgeRise + handleHeight / 2, -dimensions.thickness / 2],
        [0, Math.PI, 0],
        { foldedPoints:backHandleFolded, artworkSide:"back" })
    ];
    const bottom = panel("bottom", "折叠盒底", length, depth,
      [centers[0], -height / 2 - bottomDepth / 2, 0],
      [0, -height / 2, 0],
      [Math.PI / 2, 0, 0],
      { artworkSide:"bottom", optional:true });
    return [...body, ...roof, ...handles, bottom];
  }

  function rectangularPackagePanels(dimensions) {
    const length = dimensions.length;
    const depth = dimensions.width;
    const height = dimensions.height;
    const panels = [
      panel("front", "正面", length, height, [0, 0, 0], [0, 0, depth / 2], [0, 0, 0]),
      panel("right", "右侧", depth, height, [(length + depth) / 2, 0, 0], [length / 2, 0, 0], [0, Math.PI / 2, 0]),
      panel("back", "背面", length, height, [length + depth, 0, 0], [0, 0, -depth / 2], [0, Math.PI, 0]),
      panel("left", "左侧", depth, height, [-(length + depth) / 2, 0, 0], [-length / 2, 0, 0], [0, -Math.PI / 2, 0]),
      panel("top", "顶面", length, depth, [0, (height + depth) / 2, 0], [0, height / 2, 0], [-Math.PI / 2, 0, 0], { optional:true }),
      panel("bottom", "底面", length, depth, [0, -(height + depth) / 2, 0], [0, -height / 2, 0], [Math.PI / 2, 0, 0], { optional:true })
    ];
    const openTopFamilies = new Set(["sleeve", "paper-bag", "tray"]);
    const openBottomFamilies = new Set(["sleeve"]);
    return panels.filter(item => !(item.id === "top" && openTopFamilies.has(dimensions.family))
      && !(item.id === "bottom" && openBottomFamilies.has(dimensions.family)));
  }

  function exactPanel(id, label, width, height, flatPosition, foldedPoints, artworkSide, options = {}) {
    return panel(id, label, width, height, flatPosition, [0, 0, 0], [0, 0, 0], {
      ...options,
      foldedPoints,
      artworkSide
    });
  }

  function rectangularBodyPanels(widths, height) {
    const length = positiveDimension(widths[0], "盒身正面宽度");
    const depth = positiveDimension(widths[1], "盒身侧面宽度");
    const total = widths.reduce((sum, value) => sum + positiveDimension(value, "盒身面宽度"), 0);
    const centers = [];
    let cursor = -total / 2;
    widths.forEach(value => {
      centers.push(cursor + value / 2);
      cursor += value;
    });
    return {
      length,
      depth,
      centers,
      panels:[
        panel("front", "正面盒身", length, height, [centers[0], 0, 0], [0, 0, depth / 2], [0, 0, 0]),
        panel("right", "右侧盒身", depth, height, [centers[1], 0, 0], [length / 2, 0, 0], [0, Math.PI / 2, 0]),
        panel("back", "背面盒身", length, height, [centers[2], 0, 0], [0, 0, -depth / 2], [0, Math.PI, 0]),
        panel("left", "左侧盒身", depth, height, [centers[3], 0, 0], [-length / 2, 0, 0], [0, -Math.PI / 2, 0])
      ]
    };
  }

  function rectangularClosurePoints(index, y, length, depth, flapDepth) {
    const inset = Math.max(.1, Math.min(flapDepth, index % 2 === 0 ? depth : length));
    if (index === 0) return [
      [-length / 2, y, depth / 2],
      [length / 2, y, depth / 2],
      [length / 2, y, depth / 2 - inset],
      [-length / 2, y, depth / 2 - inset]
    ];
    if (index === 1) return [
      [length / 2, y, depth / 2],
      [length / 2, y, -depth / 2],
      [length / 2 - inset, y, -depth / 2],
      [length / 2 - inset, y, depth / 2]
    ];
    if (index === 2) return [
      [length / 2, y, -depth / 2],
      [-length / 2, y, -depth / 2],
      [-length / 2, y, -depth / 2 + inset],
      [length / 2, y, -depth / 2 + inset]
    ];
    return [
      [-length / 2, y, -depth / 2],
      [-length / 2, y, depth / 2],
      [-length / 2 + inset, y, depth / 2],
      [-length / 2 + inset, y, -depth / 2]
    ];
  }

  function closureProfilePoints(profile, width, fallbackDepth) {
    const raw = Array.isArray(profile?.points) ? profile.points : [];
    const points = raw.map(point => [
      finite(point?.x),
      Math.max(0, finite(point?.outward)),
      0
    ]).filter(point => Number.isFinite(point[0]) && Number.isFinite(point[1]));
    if (points.length >= 3) return points;
    const depth = positiveDimension(profile?.depthMm || fallbackDepth, "折翼深度");
    return [[0, 0, 0], [0, depth, 0], [width, depth, 0], [width, 0, 0]];
  }

  function mapClosureProfileToFolded(points, index, side, body, height) {
    const profileBounds = pointsBounds2D(points);
    const width = Math.max(EPSILON, profileBounds.maxX - profileBounds.minX);
    const depth = Math.max(EPSILON, profileBounds.maxY - profileBounds.minY);
    const y = side === "top" ? height / 2 : -height / 2;
    const target = rectangularClosurePoints(index, y, body.length, body.depth, depth);
    const hingeStart = target[0];
    const hingeEnd = target[1];
    const outerEnd = target[2];
    const outerStart = target[3];
    return points.map(point => {
      const u = clamp((point[0] - profileBounds.minX) / width, 0, 1);
      const v = clamp((point[1] - profileBounds.minY) / depth, 0, 1);
      const hinge = hingeStart.map((value, axis) => lerp(value, hingeEnd[axis], u));
      const outer = outerStart.map((value, axis) => lerp(value, outerEnd[axis], u));
      return hinge.map((value, axis) => lerp(value, outer[axis], v));
    });
  }

  function foldingCartonPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "folding-carton" || !meta.exact3D || !Array.isArray(meta.bodyPanels) || meta.bodyPanels.length !== 4) return null;
    const height = positiveDimension(meta.bodyHeight, "盒身高度");
    const body = rectangularBodyPanels(meta.bodyPanels, height);
    const makeClosures = (styles, profiles, y, side, primaryDepth) => (styles || []).flatMap((style, index) => {
      if (!style || style === "none") return [];
      const width = positiveDimension(meta.bodyPanels[index], "折翼宽度");
      const flapDepth = style === "dust"
        ? positiveDimension(meta.dustDepth, "防尘翼深度")
        : positiveDimension(primaryDepth, "封口翼深度");
      const profile = Array.isArray(profiles) ? profiles[index] : null;
      const profilePoints = closureProfilePoints(profile, width, flapDepth);
      const profileBounds = pointsBounds2D(profilePoints);
      const profileCenter = boundsCenter2D(profileBounds);
      const localPoints = profilePoints.map(point => [
        point[0] - profileCenter[0],
        side === "top"
          ? point[1] - profileCenter[1]
          : profileCenter[1] - point[1],
        0
      ]);
      const foldedPoints = mapClosureProfileToFolded(
        profilePoints,
        index,
        side,
        body,
        height
      );
      return [exactPanel(
        `${side}-${style}-${index}`,
        `${side === "top" ? "上口" : "下口"}${style === "dust" ? "防尘翼" : style === "tuck" ? "插舌" : style === "lock-major" ? "扣底主翼" : style === "auto-major" ? "自锁底主翼" : "锁底侧翼"}`,
        Math.max(EPSILON, profileBounds.maxX - profileBounds.minX),
        Math.max(EPSILON, profileBounds.maxY - profileBounds.minY),
        [
          body.centers[index] + profileCenter[0] - width / 2,
          side === "top"
            ? height / 2 + profileCenter[1]
            : -height / 2 - profileCenter[1],
          0
        ],
        foldedPoints,
        index === 0 ? "front" : index === 1 ? "right" : index === 2 ? "back" : "left",
        {
          localPoints,
          optional:style === "dust",
          previewRole:"exact-closure-profile"
        }
      )];
    });
    const top = makeClosures(meta.topStyles, meta.topProfiles, height / 2, "top", meta.topDepth);
    const bottom = makeClosures(meta.bottomStyles, meta.bottomProfiles, -height / 2, "bottom", meta.bottomDepth);
    const bodyPanels = meta.windowCut ? (() => {
      const windowWidth = body.length * .6;
      const windowHeight = height * .6;
      const sideWidth = (body.length - windowWidth) / 2;
      const stripHeight = (height - windowHeight) / 2;
      const frontZ = body.depth / 2;
      const frontFlatX = body.centers[0];
      const face = (id, label, width, panelHeight, flatX, flatY, foldedX, foldedY) => exactPanel(
        id,
        label,
        width,
        panelHeight,
        [flatX, flatY, 0],
        [
          [foldedX - width / 2, foldedY + panelHeight / 2, frontZ],
          [foldedX + width / 2, foldedY + panelHeight / 2, frontZ],
          [foldedX + width / 2, foldedY - panelHeight / 2, frontZ],
          [foldedX - width / 2, foldedY - panelHeight / 2, frontZ]
        ],
        "front"
      );
      return [
        face("front-window-top", "开窗正面上框", body.length, stripHeight,
          frontFlatX, height / 2 - stripHeight / 2, 0, height / 2 - stripHeight / 2),
        face("front-window-bottom", "开窗正面下框", body.length, stripHeight,
          frontFlatX, -height / 2 + stripHeight / 2, 0, -height / 2 + stripHeight / 2),
        face("front-window-left", "开窗正面左框", sideWidth, windowHeight,
          frontFlatX - body.length / 2 + sideWidth / 2, 0,
          -body.length / 2 + sideWidth / 2, 0),
        face("front-window-right", "开窗正面右框", sideWidth, windowHeight,
          frontFlatX + body.length / 2 - sideWidth / 2, 0,
          body.length / 2 - sideWidth / 2, 0),
        ...body.panels.filter(facePanel => facePanel.id !== "front")
      ];
    })() : body.panels;
    const hanger = meta.topStyle === "hanger" && Number(meta.hangerExtra) > 0
      ? [exactPanel("hanger-tab", "挂耳展示片", body.length * .64, meta.hangerExtra,
        [body.centers[meta.topPrimary || 0], height / 2 + meta.topDepth + meta.hangerExtra / 2, 0],
        [
          [-body.length * .32, height / 2 + meta.hangerExtra, 0],
          [body.length * .32, height / 2 + meta.hangerExtra, 0],
          [body.length * .32, height / 2, 0],
          [-body.length * .32, height / 2, 0]
        ],
        "front"
      )]
      : [];
    const lockSlot = meta.lockSlot ? [exactPanel(
      "insert-lock-slot",
      "承口插扣",
      Math.min(body.length * .28, 28),
      Math.max(2.4, Math.min(5, body.depth * .08)),
      [body.centers[meta.topPrimary === 0 ? 2 : 0], height / 2 + meta.topDepth * .34, 0],
      [
        [-Math.min(body.length * .28, 28) / 2, height / 2 + .08, 1.2],
        [Math.min(body.length * .28, 28) / 2, height / 2 + .08, 1.2],
        [Math.min(body.length * .28, 28) / 2, height / 2 + .08, -1.2],
        [-Math.min(body.length * .28, 28) / 2, height / 2 + .08, -1.2]
      ],
      "top",
      { optional:true, featureColor:"#253041" }
    )] : [];
    return [...bodyPanels, ...top, ...bottom, ...hanger, ...lockSlot];
  }

  function sleevePackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "sleeve" || !meta.exact3D || !Array.isArray(meta.bodyPanels) || meta.bodyPanels.length !== 4) return null;
    return rectangularBodyPanels(meta.bodyPanels, positiveDimension(meta.sleeveLength, "套筒长度")).panels;
  }

  function mailerPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "mailer" || !meta.exact3D || !meta.base || !meta.lid) return null;
    const length = positiveDimension(meta.base.length, "邮寄盒长度");
    const depth = positiveDimension(meta.base.width, "邮寄盒宽度");
    const height = positiveDimension(meta.wallHeight, "邮寄盒高度");
    const tuck = positiveDimension(meta.tuckDepth, "邮寄盒插舌");
    const bottomY = -height / 2;
    const topY = height / 2;
    const panels = [
      exactPanel("mailer-bottom", "盒底", length, depth, [0, 0, 0], [
        [-length / 2, bottomY, depth / 2], [length / 2, bottomY, depth / 2],
        [length / 2, bottomY, -depth / 2], [-length / 2, bottomY, -depth / 2]
      ], "bottom"),
      exactPanel("mailer-front", "前壁", length, height, [0, (depth + height) / 2, 0], [
        [-length / 2, topY, depth / 2], [length / 2, topY, depth / 2],
        [length / 2, bottomY, depth / 2], [-length / 2, bottomY, depth / 2]
      ], "front"),
      exactPanel("mailer-right", "右壁", depth, height, [(length + height) / 2, 0, 0], [
        [length / 2, topY, -depth / 2], [length / 2, topY, depth / 2],
        [length / 2, bottomY, depth / 2], [length / 2, bottomY, -depth / 2]
      ], "right"),
      exactPanel("mailer-back", "后壁", length, height, [0, -(depth + height) / 2, 0], [
        [length / 2, topY, -depth / 2], [-length / 2, topY, -depth / 2],
        [-length / 2, bottomY, -depth / 2], [length / 2, bottomY, -depth / 2]
      ], "back"),
      exactPanel("mailer-left", "左壁", depth, height, [-(length + height) / 2, 0, 0], [
        [-length / 2, topY, depth / 2], [-length / 2, topY, -depth / 2],
        [-length / 2, bottomY, -depth / 2], [-length / 2, bottomY, depth / 2]
      ], "left"),
      exactPanel("mailer-lid", "翻盖", length, depth, [0, -(depth * 1.5 + height), 0], [
        [-length / 2, topY, depth / 2], [length / 2, topY, depth / 2],
        [length / 2, topY, -depth / 2], [-length / 2, topY, -depth / 2]
      ], "top"),
    ];
    if (!meta.lockSlot) {
      panels.push(exactPanel("mailer-tuck", "翻盖插舌", length, tuck, [0, -(depth * 2 + height + tuck / 2), 0], [
        [-length / 2, topY - tuck, depth / 2], [length / 2, topY - tuck, depth / 2],
        [length / 2, topY, depth / 2], [-length / 2, topY, depth / 2]
      ], "front"));
    } else {
      const centerWidth = length * .46;
      const earWidth = length * .2;
      const earOffset = length * .32;
      const makeTuckPart = (id, label, width, centerX) => exactPanel(
        id,
        label,
        width,
        tuck,
        [centerX, -(depth * 2 + height + tuck / 2), 0],
        [
          [centerX - width / 2, topY - tuck, depth / 2],
          [centerX + width / 2, topY - tuck, depth / 2],
          [centerX + width / 2, topY, depth / 2],
          [centerX - width / 2, topY, depth / 2]
        ],
        "front"
      );
      panels.push(
        makeTuckPart("mailer-tuck-center", "翻盖中央插舌", centerWidth, 0),
        makeTuckPart("mailer-lock-ear-left", "左锁耳", earWidth, -earOffset),
        makeTuckPart("mailer-lock-ear-right", "右锁耳", earWidth, earOffset)
      );
    }
    return panels;
  }

  function trayPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "tray" || !meta.exact3D || !meta.base) return null;
    const length = positiveDimension(meta.base.length, "托盘长度");
    const depth = positiveDimension(meta.base.width, "托盘宽度");
    const height = positiveDimension(meta.wallHeight, "托盘壁高");
    const bottomY = -height / 2;
    const topY = height / 2;
    return [
      exactPanel("tray-bottom", "托盘底板", length, depth, [0, 0, 0], [
        [-length / 2, bottomY, depth / 2], [length / 2, bottomY, depth / 2],
        [length / 2, bottomY, -depth / 2], [-length / 2, bottomY, -depth / 2]
      ], "bottom"),
      exactPanel("tray-front", "托盘前壁", length, height, [0, (depth + height) / 2, 0], [
        [-length / 2, topY, depth / 2], [length / 2, topY, depth / 2],
        [length / 2, bottomY, depth / 2], [-length / 2, bottomY, depth / 2]
      ], "front"),
      exactPanel("tray-right", "托盘右壁", depth, height, [(length + height) / 2, 0, 0], [
        [length / 2, topY, -depth / 2], [length / 2, topY, depth / 2],
        [length / 2, bottomY, depth / 2], [length / 2, bottomY, -depth / 2]
      ], "right"),
      exactPanel("tray-back", "托盘后壁", length, height, [0, -(depth + height) / 2, 0], [
        [length / 2, topY, -depth / 2], [-length / 2, topY, -depth / 2],
        [-length / 2, bottomY, -depth / 2], [length / 2, bottomY, -depth / 2]
      ], "back"),
      exactPanel("tray-left", "托盘左壁", depth, height, [-(length + height) / 2, 0, 0], [
        [-length / 2, topY, depth / 2], [-length / 2, topY, -depth / 2],
        [-length / 2, bottomY, -depth / 2], [-length / 2, bottomY, depth / 2]
      ], "left")
    ];
  }

  function paperBagPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "paper-bag" || !meta.exact3D || !Array.isArray(meta.bodyPanels) || meta.bodyPanels.length !== 4) return null;
    const height = positiveDimension(meta.bagHeight, "纸袋高度");
    const body = rectangularBodyPanels(meta.bodyPanels, height);
    const bottomY = -height / 2;
    const bottom = exactPanel("bag-bottom", "纸袋糊底", body.length, body.depth, [0, -height / 2 - meta.bottomDepth / 2, 0], [
      [-body.length / 2, bottomY, body.depth / 2], [body.length / 2, bottomY, body.depth / 2],
      [body.length / 2, bottomY, -body.depth / 2], [-body.length / 2, bottomY, -body.depth / 2]
    ], "bottom");
    const handleHeight = Math.max(12, Math.min(42, height * .16));
    const handleWidth = positiveDimension(meta.handleWidth, "纸袋提手宽度");
    const makeHandle = (id, z, flatX, side) => exactPanel(id, "纸袋提手", handleWidth, handleHeight,
      [flatX, height / 2 + handleHeight / 2, 0], [
        [-handleWidth / 2, height / 2 + handleHeight, z],
        [handleWidth / 2, height / 2 + handleHeight, z],
        [handleWidth / 2, height / 2, z],
        [-handleWidth / 2, height / 2, z]
      ], side, { optional:true });
    return [...body.panels, bottom,
      makeHandle("bag-handle-front", body.depth / 2, body.centers[0], "front"),
      makeHandle("bag-handle-back", -body.depth / 2, body.centers[2], "back")
    ];
  }

  function envelopePackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "envelope" || !meta.exact3D || !meta.body) return null;
    const length = positiveDimension(meta.body.length, "平粘盒长度");
    const depth = positiveDimension(meta.body.width, "平粘盒厚度");
    const height = positiveDimension(meta.body.depth, "平粘盒高度");
    return [
      exactPanel("envelope-front", "平粘盒正面", length, height, [0, 0, 0], [
        [-length / 2, height / 2, depth / 2], [length / 2, height / 2, depth / 2],
        [length / 2, -height / 2, depth / 2], [-length / 2, -height / 2, depth / 2]
      ], "front"),
      exactPanel("envelope-back", "平粘盒背面", length, height, [length + depth, 0, 0], [
        [length / 2, height / 2, -depth / 2], [-length / 2, height / 2, -depth / 2],
        [-length / 2, -height / 2, -depth / 2], [length / 2, -height / 2, -depth / 2]
      ], "back"),
      exactPanel("envelope-left", "平粘盒左侧", depth, height, [-(length + depth) / 2, 0, 0], [
        [-length / 2, height / 2, depth / 2], [-length / 2, height / 2, -depth / 2],
        [-length / 2, -height / 2, -depth / 2], [-length / 2, -height / 2, depth / 2]
      ], "left"),
      exactPanel("envelope-right", "平粘盒右侧", depth, height, [(length + depth) / 2, 0, 0], [
        [length / 2, height / 2, -depth / 2], [length / 2, height / 2, depth / 2],
        [length / 2, -height / 2, depth / 2], [length / 2, -height / 2, -depth / 2]
      ], "right"),
      exactPanel("envelope-bottom", "平粘盒底面", length, depth, [0, -(height + depth) / 2, 0], [
        [-length / 2, -height / 2, depth / 2], [length / 2, -height / 2, depth / 2],
        [length / 2, -height / 2, -depth / 2], [-length / 2, -height / 2, -depth / 2]
      ], "bottom"),
      exactPanel("envelope-top", "平粘盒顶面", length, depth, [length + depth, height / 2 + depth / 2, 0], [
        [-length / 2, height / 2, -depth / 2], [length / 2, height / 2, -depth / 2],
        [length / 2, height / 2, depth / 2], [-length / 2, height / 2, depth / 2]
      ], "top")
    ];
  }

  function pillowPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "pillow" || !meta.exact3D) return null;
    const length = positiveDimension(meta.bodyLength, "枕形盒长度");
    const width = positiveDimension(meta.finishedWidth, "枕形盒宽度");
    const depth = positiveDimension(meta.finishedDepth, "枕形盒厚度");
    const segments = 12;
    const faces = [];
    for (let index = 0; index < segments; index += 1) {
      const a0 = Math.PI * 2 * index / segments;
      const a1 = Math.PI * 2 * (index + 1) / segments;
      const points = [
        [-length / 2, Math.sin(a0) * depth / 2, Math.cos(a0) * width / 2],
        [length / 2, Math.sin(a0) * depth / 2, Math.cos(a0) * width / 2],
        [length / 2, Math.sin(a1) * depth / 2, Math.cos(a1) * width / 2],
        [-length / 2, Math.sin(a1) * depth / 2, Math.cos(a1) * width / 2]
      ];
      faces.push(exactPanel(`pillow-body-${index}`, `枕形曲面${index + 1}`, length, Math.hypot(width, depth) * Math.PI / segments,
        [0, (index - segments / 2) * Math.hypot(width, depth) / segments, 0], points,
        index < segments / 2 ? "front" : "back"));
    }
    const endPoints = side => Array.from({ length:segments }, (_, index) => {
      const angle = Math.PI * 2 * (side > 0 ? index : segments - 1 - index) / segments;
      return [side * length / 2, Math.sin(angle) * depth / 2, Math.cos(angle) * width / 2];
    });
    faces.push(exactPanel("pillow-end-left", "左端弧盖", width, depth, [-length / 2, 0, 0], endPoints(-1), "left", {
      localPoints:Array.from({ length:segments }, (_, index) => {
        const angle = Math.PI * 2 * index / segments;
        return [Math.cos(angle) * width / 2, Math.sin(angle) * depth / 2, 0];
      })
    }));
    faces.push(exactPanel("pillow-end-right", "右端弧盖", width, depth, [length / 2, 0, 0], endPoints(1), "right", {
      localPoints:Array.from({ length:segments }, (_, index) => {
        const angle = Math.PI * 2 * index / segments;
        return [Math.cos(angle) * width / 2, Math.sin(angle) * depth / 2, 0];
      })
    }));
    return faces;
  }

  function polygonPackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "polygon-carton" || !meta.exact3D) return null;
    const sides = Math.max(5, Math.round(positiveDimension(meta.sides, "多边形边数")));
    const sideWidth = positiveDimension(meta.sideWidth, "多边形边宽");
    const height = positiveDimension(meta.bodyHeight, "多边形盒高");
    const radius = sideWidth / (2 * Math.sin(Math.PI / sides));
    const faces = [];
    for (let index = 0; index < sides; index += 1) {
      const a0 = Math.PI * 2 * index / sides;
      const a1 = Math.PI * 2 * (index + 1) / sides;
      const points = [
        [Math.sin(a0) * radius, height / 2, Math.cos(a0) * radius],
        [Math.sin(a1) * radius, height / 2, Math.cos(a1) * radius],
        [Math.sin(a1) * radius, -height / 2, Math.cos(a1) * radius],
        [Math.sin(a0) * radius, -height / 2, Math.cos(a0) * radius]
      ];
      faces.push(exactPanel(`polygon-side-${index}`, `第${index + 1}侧面`, sideWidth, height,
        [(index - sides / 2) * sideWidth, 0, 0], points, index === 0 ? "front" : "outside"));
    }
    const polygonPoints = y => Array.from({ length:sides }, (_, index) => {
      const angle = Math.PI * 2 * index / sides;
      return [Math.sin(angle) * radius, y, Math.cos(angle) * radius];
    });
    const localPoints = Array.from({ length:sides }, (_, index) => {
      const angle = Math.PI * 2 * index / sides;
      return [Math.sin(angle) * radius, Math.cos(angle) * radius, 0];
    });
    faces.push(exactPanel("polygon-top", "多边形顶盖", radius * 2, radius * 2, [0, height / 2 + radius, 0], polygonPoints(height / 2), "top", { localPoints }));
    faces.push(exactPanel("polygon-bottom", "多边形底盖", radius * 2, radius * 2, [0, -height / 2 - radius, 0], polygonPoints(-height / 2).reverse(), "bottom", { localPoints }));
    return faces;
  }

  function lidBasePackagePanels(dimensions) {
    const meta = dimensions.source?.structureMeta || {};
    if (meta.kind !== "lid-base" || !meta.exact3D || !meta.base || !meta.lid) return null;
    const baseLength = positiveDimension(meta.base.length, "盒底长度");
    const baseWidth = positiveDimension(meta.base.width, "盒底宽度");
    const baseDepth = positiveDimension(meta.base.depth, "盒底高度");
    const lidLength = positiveDimension(meta.lid.length, "上盖长度");
    const lidWidth = positiveDimension(meta.lid.width, "上盖宽度");
    const lidDepth = positiveDimension(meta.lid.depth, "上盖高度");
    const thickness = Math.max(.05, finite(meta.thickness, dimensions.thickness));
    const gap = Math.max(24, Math.min(80, (baseLength + lidLength) * .12));
    const baseNetWidth = baseLength + baseDepth * 2;
    const lidNetWidth = lidLength + lidDepth * 2;
    const baseFlatX = -(gap / 2 + baseNetWidth / 2);
    const lidFlatX = gap / 2 + lidNetWidth / 2;
    const baseBottomY = -baseDepth / 2;
    const baseTopY = baseDepth / 2;
    const lidTopY = baseTopY + thickness;
    const lidBottomY = lidTopY - lidDepth;
    const makeFace = (id, label, width, height, flatPosition, foldedPoints, artworkSide) => panel(
      id,
      label,
      width,
      height,
      flatPosition,
      [0, 0, 0],
      [0, 0, 0],
      { foldedPoints, artworkSide }
    );
    const basePanels = [
      makeFace("base-bottom", "盒底底板", baseLength, baseWidth, [baseFlatX, 0, 0], [
        [-baseLength / 2, baseBottomY, baseWidth / 2],
        [baseLength / 2, baseBottomY, baseWidth / 2],
        [baseLength / 2, baseBottomY, -baseWidth / 2],
        [-baseLength / 2, baseBottomY, -baseWidth / 2]
      ], "bottom"),
      makeFace("base-front", "盒底前壁", baseLength, baseDepth, [baseFlatX, (baseWidth + baseDepth) / 2, 0], [
        [-baseLength / 2, baseTopY, baseWidth / 2],
        [baseLength / 2, baseTopY, baseWidth / 2],
        [baseLength / 2, baseBottomY, baseWidth / 2],
        [-baseLength / 2, baseBottomY, baseWidth / 2]
      ], "front"),
      makeFace("base-right", "盒底右壁", baseDepth, baseWidth, [baseFlatX + (baseLength + baseDepth) / 2, 0, 0], [
        [baseLength / 2, baseTopY, -baseWidth / 2],
        [baseLength / 2, baseTopY, baseWidth / 2],
        [baseLength / 2, baseBottomY, baseWidth / 2],
        [baseLength / 2, baseBottomY, -baseWidth / 2]
      ], "right"),
      makeFace("base-back", "盒底后壁", baseLength, baseDepth, [baseFlatX, -(baseWidth + baseDepth) / 2, 0], [
        [baseLength / 2, baseTopY, -baseWidth / 2],
        [-baseLength / 2, baseTopY, -baseWidth / 2],
        [-baseLength / 2, baseBottomY, -baseWidth / 2],
        [baseLength / 2, baseBottomY, -baseWidth / 2]
      ], "back"),
      makeFace("base-left", "盒底左壁", baseDepth, baseWidth, [baseFlatX - (baseLength + baseDepth) / 2, 0, 0], [
        [-baseLength / 2, baseTopY, baseWidth / 2],
        [-baseLength / 2, baseTopY, -baseWidth / 2],
        [-baseLength / 2, baseBottomY, -baseWidth / 2],
        [-baseLength / 2, baseBottomY, baseWidth / 2]
      ], "left")
    ];
    const lidPanels = [
      makeFace("lid-top", "上盖顶板", lidLength, lidWidth, [lidFlatX, 0, 0], [
        [-lidLength / 2, lidTopY, lidWidth / 2],
        [lidLength / 2, lidTopY, lidWidth / 2],
        [lidLength / 2, lidTopY, -lidWidth / 2],
        [-lidLength / 2, lidTopY, -lidWidth / 2]
      ], "top"),
      makeFace("lid-front", "上盖前壁", lidLength, lidDepth, [lidFlatX, (lidWidth + lidDepth) / 2, 0], [
        [-lidLength / 2, lidTopY, lidWidth / 2],
        [lidLength / 2, lidTopY, lidWidth / 2],
        [lidLength / 2, lidBottomY, lidWidth / 2],
        [-lidLength / 2, lidBottomY, lidWidth / 2]
      ], "front"),
      makeFace("lid-right", "上盖右壁", lidDepth, lidWidth, [lidFlatX + (lidLength + lidDepth) / 2, 0, 0], [
        [lidLength / 2, lidTopY, -lidWidth / 2],
        [lidLength / 2, lidTopY, lidWidth / 2],
        [lidLength / 2, lidBottomY, lidWidth / 2],
        [lidLength / 2, lidBottomY, -lidWidth / 2]
      ], "right"),
      makeFace("lid-back", "上盖后壁", lidLength, lidDepth, [lidFlatX, -(lidWidth + lidDepth) / 2, 0], [
        [lidLength / 2, lidTopY, -lidWidth / 2],
        [-lidLength / 2, lidTopY, -lidWidth / 2],
        [-lidLength / 2, lidBottomY, -lidWidth / 2],
        [lidLength / 2, lidBottomY, -lidWidth / 2]
      ], "back"),
      makeFace("lid-left", "上盖左壁", lidDepth, lidWidth, [lidFlatX - (lidLength + lidDepth) / 2, 0, 0], [
        [-lidLength / 2, lidTopY, lidWidth / 2],
        [-lidLength / 2, lidTopY, -lidWidth / 2],
        [-lidLength / 2, lidBottomY, -lidWidth / 2],
        [-lidLength / 2, lidBottomY, lidWidth / 2]
      ], "left")
    ];
    return [...basePanels, ...lidPanels];
  }

  function pointsBounds2D(points) {
    const xs = points.map(point => finite(point[0]));
    const ys = points.map(point => finite(point[1]));
    return {
      minX:Math.min(...xs),
      maxX:Math.max(...xs),
      minY:Math.min(...ys),
      maxY:Math.max(...ys)
    };
  }

  function boundsCenter2D(bounds) {
    return [(bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2];
  }

  function pointInsideBounds(point, bounds, margin = 1e-4) {
    return point[0] >= bounds.minX - margin && point[0] <= bounds.maxX + margin
      && point[1] >= bounds.minY - margin && point[1] <= bounds.maxY + margin;
  }

  function faceCentroid2D(points) {
    const sum = points.reduce((output, point) => [output[0] + point[0], output[1] + point[1]], [0, 0]);
    return [sum[0] / points.length, sum[1] / points.length];
  }

  function affineFoldMapper(flatPoints, foldedPoints) {
    let triple = null;
    for (let first = 0; first < flatPoints.length - 2 && !triple; first += 1) {
      for (let second = first + 1; second < flatPoints.length - 1 && !triple; second += 1) {
        for (let third = second + 1; third < flatPoints.length; third += 1) {
          const a = flatPoints[first];
          const b = flatPoints[second];
          const c = flatPoints[third];
          const determinant = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
          if (Math.abs(determinant) > EPSILON) triple = { first, second, third, determinant };
        }
      }
    }
    if (!triple) return point => [point[0], point[1], 0];
    const a = flatPoints[triple.first];
    const b = flatPoints[triple.second];
    const c = flatPoints[triple.third];
    const targetA = foldedPoints[triple.first];
    const targetB = foldedPoints[triple.second];
    const targetC = foldedPoints[triple.third];
    return point => {
      const px = point[0] - a[0];
      const py = point[1] - a[1];
      const u = (px * (c[1] - a[1]) - py * (c[0] - a[0])) / triple.determinant;
      const v = ((b[0] - a[0]) * py - (b[1] - a[1]) * px) / triple.determinant;
      return targetA.map((value, index) => value
        + u * (targetB[index] - value)
        + v * (targetC[index] - value));
    };
  }

  function pointSegmentDistance2D(point, start, end) {
    const dx = end[0] - start[0];
    const dy = end[1] - start[1];
    const lengthSquared = dx * dx + dy * dy;
    if (lengthSquared <= EPSILON) return Math.hypot(point[0] - start[0], point[1] - start[1]);
    const amount = clamp(
      ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared,
      0,
      1
    );
    return Math.hypot(
      point[0] - (start[0] + dx * amount),
      point[1] - (start[1] + dy * amount)
    );
  }

  function pointPolygonBoundaryDistance2D(point, polygon) {
    let distance = Infinity;
    for (let index = 0; index < polygon.length; index += 1) {
      distance = Math.min(
        distance,
        pointSegmentDistance2D(point, polygon[index], polygon[(index + 1) % polygon.length])
      );
    }
    return distance;
  }

  function polygonBoundaryMatchError(first, second) {
    const combinedBounds = pointsBounds2D([...first, ...second]);
    const normalizer = Math.max(
      EPSILON,
      Math.hypot(
        combinedBounds.maxX - combinedBounds.minX,
        combinedBounds.maxY - combinedBounds.minY
      )
    );
    const firstError = first.reduce(
      (total, point) => total + pointPolygonBoundaryDistance2D(point, second),
      0
    ) / Math.max(1, first.length);
    const secondError = second.reduce(
      (total, point) => total + pointPolygonBoundaryDistance2D(point, first),
      0
    ) / Math.max(1, second.length);
    return (firstError + secondError) / normalizer;
  }

  function bestPanelCoordinateMapper(sourcePolygon, targetPolygon) {
    const sourceBounds = pointsBounds2D(sourcePolygon);
    const targetBounds = pointsBounds2D(targetPolygon);
    const sourceWidth = Math.max(EPSILON, sourceBounds.maxX - sourceBounds.minX);
    const sourceHeight = Math.max(EPSILON, sourceBounds.maxY - sourceBounds.minY);
    const targetWidth = Math.max(EPSILON, targetBounds.maxX - targetBounds.minX);
    const targetHeight = Math.max(EPSILON, targetBounds.maxY - targetBounds.minY);
    const candidates = [];
    [false, true].forEach(swapAxes => {
      [false, true].forEach(flipX => {
        [false, true].forEach(flipY => {
          const mapper = point => {
            const normalizedX = (point[0] - sourceBounds.minX) / sourceWidth;
            const normalizedY = (point[1] - sourceBounds.minY) / sourceHeight;
            let u = swapAxes ? normalizedY : normalizedX;
            let v = swapAxes ? normalizedX : normalizedY;
            if (flipX) u = 1 - u;
            if (flipY) v = 1 - v;
            return [
              targetBounds.minX + u * targetWidth,
              targetBounds.minY + v * targetHeight,
              0
            ];
          };
          const mapped = sourcePolygon.map(mapper);
          const aspectPenalty = swapAxes
            ? Math.abs(sourceWidth / sourceHeight - targetHeight / targetWidth) * 1e-4
            : Math.abs(sourceWidth / sourceHeight - targetWidth / targetHeight) * 1e-4;
          const transformationPenalty = (swapAxes ? 3e-7 : 0)
            + (flipX ? 2e-7 : 0)
            + (flipY ? 1e-7 : 0);
          candidates.push({
            mapper,
            score:polygonBoundaryMatchError(mapped, targetPolygon)
              + aspectPenalty
              + transformationPenalty
          });
        });
      });
    });
    candidates.sort((first, second) => first.score - second.score);
    return candidates[0].mapper;
  }

  function topologyLinkedPanels(dimensions, schematicPanels) {
    const topology = dimensions.source?.structureMeta?.netTopology;
    const polygons = Array.isArray(topology?.facePolygons)
      ? topology.facePolygons
        .map(polygon => polygon.map(point => [finite(point.x), finite(point.y), 0]))
        .filter(polygon => polygon.length >= 3)
      : [];
    if (!polygons.length || !schematicPanels?.length) return null;
    const topologyBounds = pointsBounds2D(polygons.flat());
    const topologyCenter = boundsCenter2D(topologyBounds);
    const centeredPolygons = polygons.map(polygon => polygon.map(point => [
      point[0] - topologyCenter[0],
      topologyCenter[1] - point[1],
      0
    ]));
    const schematicFlat = schematicPanels.map(face => panelVertices(face, 0));
    const schematicBounds = pointsBounds2D(schematicFlat.flat());
    const schematicCenter = boundsCenter2D(schematicBounds);
    const alignedSchematicFlat = schematicFlat.map(points => points.map(point => [
      point[0] - schematicCenter[0],
      point[1] - schematicCenter[1],
      point[2]
    ]));
    const panelRecords = schematicPanels.map((face, index) => {
      const flat = alignedSchematicFlat[index];
      const bounds = pointsBounds2D(flat);
      const center = boundsCenter2D(bounds);
      return {
        face,
        flat,
        folded:panelVertices(face, 1),
        bounds,
        center,
        mapper:affineFoldMapper(flat, panelVertices(face, 1))
      };
    });
    const usages = new Map();
    const exactPanels = centeredPolygons.map((polygon, index) => {
      const center = faceCentroid2D(polygon);
      const candidates = panelRecords.map(record => {
        const distance = Math.hypot(center[0] - record.center[0], center[1] - record.center[1]);
        const contains = pointInsideBounds(center, record.bounds);
        const priorUse = usages.get(record.face.id) || 0;
        return { record, score:distance + (contains ? -100000 : 0) + priorUse * 1000000 };
      }).sort((first, second) => first.score - second.score);
      const matched = candidates[0].record;
      const used = usages.get(matched.face.id) || 0;
      usages.set(matched.face.id, used + 1);
      const id = used === 0 ? matched.face.id : `${matched.face.id}-net-${used + 1}`;
      const mapToMatchedFlat = bestPanelCoordinateMapper(polygon, matched.flat);
      const mapIntoMatchedPanel = point => matched.mapper(mapToMatchedFlat(point));
      return panel(
        id,
        matched.face.label,
        Math.max(EPSILON, pointsBounds2D(polygon).maxX - pointsBounds2D(polygon).minX),
        Math.max(EPSILON, pointsBounds2D(polygon).maxY - pointsBounds2D(polygon).minY),
        [center[0], center[1], 0],
        [0, 0, 0],
        [0, 0, 0],
        {
          localPoints:polygon.map(point => [point[0] - center[0], point[1] - center[1], 0]),
          foldedPoints:polygon.map(mapIntoMatchedPanel),
          artworkSide:matched.face.artworkSide,
          optional:matched.face.optional
        }
      );
    });
    return exactPanels.length >= Math.min(3, schematicPanels.length) ? exactPanels : null;
  }

  function buildFoldModel(dieline, options = {}) {
    const dimensions = readDielineDimensions(dieline);
    const familyBuilders = {
      "folding-carton":foldingCartonPackagePanels,
      "mailer":mailerPackagePanels,
      "lid-base":lidBasePackagePanels,
      "sleeve":sleevePackagePanels,
      "paper-bag":paperBagPackagePanels,
      "envelope":envelopePackagePanels,
      "pillow":pillowPackagePanels,
      "gable":gablePackagePanels,
      "polygon-carton":polygonPackagePanels,
      "tray":trayPackagePanels
    };
    const schematicPanels = familyBuilders[dimensions.family]?.(dimensions) || null;
    /*
     * A production net is not a semantic face list. The cut/fold splitter can
     * merge a glue flap into a printable region, omit a virtual closing face,
     * or split one shaped tuck flap into several polygons. Matching those raw
     * polygons to front/back/lid panels by position made the selected code look
     * correct while folding the wrong faces. The family builder consumes the
     * same structureMeta and dimensions as the production dieline and keeps
     * the semantic roles explicit, so it must be the source of the interactive
     * folded preview. SVG/PDF/DXF continue to use the exact production net.
     */
    const linkedPanels = schematicPanels;
    const panels = schematicPanels || rectangularPackagePanels(dimensions);
    const sourceNetPanels = linkedPanels ? sourceNetPreviewPanels(dimensions) : [];
    const largest = Math.max(dimensions.length, dimensions.width, dimensions.height);
    const allPreviewPoints = panels.flatMap(face => [
      ...panelVertices(face, 0),
      ...panelVertices(face, 1)
    ]);
    const previewBounds = pointsBounds2D(allPreviewPoints);
    const previewSpan = Math.max(
      previewBounds.maxX - previewBounds.minX,
      previewBounds.maxY - previewBounds.minY
    );
    const notes = [
      "这是用于沟通、校对结构方向和画面位置的交互预览，不是生产刀模数据。",
      "正式生产仍以现有 SVG/PDF/DXF 刀线、材料厚度、桥位和实物打样为准。"
    ];
    const linkedNotes = {
      "folding-carton":"当前3D已读取本次刀板的四片盒身、上口插舌/防尘翼、扣底或自锁底结构；切换卡盒型号会同步更换闭合结构。",
      "mailer":"当前3D已读取本次刀板的盒底、四壁、翻盖、插舌与锁口结构。",
      "lid-base":"当前3D已读取本次刀板的盒底与上盖两套十字结构，并按实际套合尺寸生成分体天地盖。",
      "sleeve":"当前3D已读取本次刀板的四片围边结构，并保持两端开口。",
      "paper-bag":"当前3D已读取本次刀板的袋身、侧褶、糊底和提手结构。",
      "envelope":"当前3D已读取本次刀板的正背面与封口舌结构。",
      "pillow":"当前3D已读取本次刀板的枕形曲面与两端弧盖结构。",
      "gable":"当前3D已读取本次刀板的盒身、屋面、三角屋脊、提手和盒底参数；修改尺寸后两处会同步重算。",
      "polygon-carton":"当前3D已读取本次刀板的多边形边数、各侧面与上下封口结构。",
      "tray":"当前3D已读取本次刀板的底板、四壁与开口托盘结构。"
    };
    if (linkedPanels) notes.push(linkedNotes[dimensions.family] || "当前3D与本次刀板共用同一份结构参数。");
    else notes.push("当前刀板缺少可用结构元数据，已退回成品外包络预览；请勿据此判断生产结构。");
    return {
      version:VERSION,
      id:String(options.id || `${dimensions.type}-${Date.now()}`),
      templateId:String(dieline?.options?.definition?.id || dimensions.type || ""),
      geometrySignature:String(dieline?.geometrySignature || ""),
      geometryBounds:dieline?.geometryBounds ? { ...dieline.geometryBounds } : null,
      name:dimensions.name,
      code:dimensions.code,
      family:dimensions.family,
      type:dimensions.type,
      material:dimensions.material,
      dimensions:{
        lengthMm:dimensions.length,
        widthMm:dimensions.width,
        heightMm:dimensions.height,
        thicknessMm:dimensions.thickness
      },
      panels,
      sourceNetPanels,
      sourceNetLinked:Boolean(sourceNetPanels.length),
      structureLinked:Boolean(linkedPanels),
      netTopologyLinked:Boolean(
        linkedPanels
        && dimensions.source?.structureMeta?.netTopology?.facePolygons?.length
      ),
      netTopologyFaceCount:dimensions.source?.structureMeta?.netTopology?.facePolygons?.length || 0,
      structureSource:linkedPanels
        ? "dieline.structureMeta.semantic3D"
        : "finished-size-envelope",
      radius:Math.max(1, largest * .82, previewSpan * .58),
      precision:"visual-preview-only",
      productionExportSource:"JianglaiDieLayout SVG/PDF/DXF",
      notes
    };
  }

  function panelVertices(face, foldProgress) {
    const amount = easeFold(foldProgress);
    const position = face.flatPosition.map((value, index) => lerp(value, face.foldedPosition[index], amount));
    const rotation = slerpQuaternion(face.flatRotation, face.foldedRotation, amount);
    const halfWidth = face.width / 2;
    const halfHeight = face.height / 2;
    const points = Array.isArray(face.localPoints) && face.localPoints.length >= 3
      ? face.localPoints
      : [
          [-halfWidth, halfHeight, 0],
          [halfWidth, halfHeight, 0],
          [halfWidth, -halfHeight, 0],
          [-halfWidth, -halfHeight, 0]
        ];
    if (Array.isArray(face.foldedPoints) && face.foldedPoints.length === points.length) {
      return points.map((point, pointIndex) => {
        const flatPoint = point.map((value, index) => value + face.flatPosition[index]);
        const foldedPoint = face.foldedPoints[pointIndex];
        return flatPoint.map((value, index) => lerp(value, foldedPoint[index], amount));
      });
    }
    return points.map(point => {
      const rotated = rotateByQuaternion(point, rotation);
      return rotated.map((value, index) => value + position[index]);
    });
  }

  function rotateScene(point, yaw, pitch) {
    const cosineY = Math.cos(yaw), sineY = Math.sin(yaw);
    const x1 = point[0] * cosineY + point[2] * sineY;
    const z1 = -point[0] * sineY + point[2] * cosineY;
    const cosineX = Math.cos(pitch), sineX = Math.sin(pitch);
    return [
      x1,
      point[1] * cosineX - z1 * sineX,
      point[1] * sineX + z1 * cosineX
    ];
  }

  function sceneFrame(model, foldProgress) {
    const hasSourceNet = Array.isArray(model.sourceNetPanels) && model.sourceNetPanels.length > 0;
    const visiblePanels = [
      ...(Array.isArray(model.sourceNetPanels) ? model.sourceNetPanels : []),
      ...model.panels
    ].filter(face => panelVisibility(face, foldProgress, hasSourceNet) > .001);
    const points = visiblePanels.flatMap(face => panelVertices(face, foldProgress));
    if (!points.length) {
      return {
        center:[0, 0, 0],
        radius:Math.max(1, finite(model.radius, 1))
      };
    }
    const bounds = [0, 1, 2].map(index => ({
      min:Math.min(...points.map(point => finite(point[index]))),
      max:Math.max(...points.map(point => finite(point[index])))
    }));
    const center = bounds.map(bound => (bound.min + bound.max) / 2);
    const spans = bounds.map(bound => Math.max(0, bound.max - bound.min));
    const radius = Math.max(
      1,
      Math.hypot(spans[0], spans[1], spans[2]) * .56
    );
    return { center, radius };
  }

  function projectScene(model, state, viewport = {}) {
    const width = Math.max(1, finite(viewport.width, 800));
    const height = Math.max(1, finite(viewport.height, 520));
    const aspect = width / height;
    const foldProgress = clamp(state.fold ?? .72, 0, 1);
    const frame = sceneFrame(model, foldProgress);
    const radius = frame.radius;
    const zoom = clamp(state.zoom ?? 1, .35, 3.5);
    const cameraDistance = radius * 3.72 / zoom;
    const near = Math.max(.1, radius * .05);
    const far = cameraDistance + radius * 5;
    const semanticTransition = clamp((foldProgress - .02) / .14, 0, 1);
    const hasSourceNet = Array.isArray(model.sourceNetPanels) && model.sourceNetPanels.length > 0;
    const yaw = finite(state.yaw, -.55) * semanticTransition;
    const pitch = finite(state.pitch, -.32) * semanticTransition;
    const visiblePanels = [
      ...(Array.isArray(model.sourceNetPanels) ? model.sourceNetPanels : []),
      ...model.panels
    ].filter(face => panelVisibility(face, foldProgress, hasSourceNet) > .001);
    return visiblePanels.map(face => {
      const world = panelVertices(face, foldProgress).map(point => rotateScene(
        point.map((value, index) => value - frame.center[index]),
        yaw,
        pitch
      ));
      const projected = world.map(point => {
        const depth = Math.max(near, cameraDistance - point[2]);
        const focal = cameraDistance * .88;
        return {
          x:(point[0] * focal / depth) / radius / aspect,
          y:(point[1] * focal / depth) / radius,
          z:clamp(((depth - near) / (far - near)) * 2 - 1, -1, 1),
          depth
        };
      });
      const normal = normalizeVector(cross(subtract(world[1], world[0]), subtract(world[world.length - 1], world[0])));
      const keyLight = normalizeVector([.38, .76, .72]);
      const fillLight = normalizeVector([-.72, .24, .54]);
      const key = Math.abs(normal.reduce((sum, value, index) => sum + value * keyLight[index], 0));
      const fill = Math.abs(normal.reduce((sum, value, index) => sum + value * fillLight[index], 0));
      const rim = Math.pow(1 - Math.abs(normal[2]), 2);
      const shade = clamp(.47 + key * .39 + fill * .12 + rim * .06, .4, 1.04);
      return {
        face,
        world,
        points:projected,
        depth:projected.reduce((sum, point) => sum + point.depth, 0) / projected.length,
        normal,
        shade,
        visibility:panelVisibility(face, foldProgress, hasSourceNet)
      };
    });
  }

  function parseColor(value) {
    const match = String(value || "").trim().match(/^#?([0-9a-f]{6})$/i);
    if (!match) return [0.73, 0.59, 0.42];
    const number = parseInt(match[1], 16);
    return [(number >> 16 & 255) / 255, (number >> 8 & 255) / 255, (number & 255) / 255];
  }

  function materialPreviewColor(material) {
    const value = String(material || "").toLowerCase();
    if (/牛卡|牛皮|kraft|瓦楞|坑纸/.test(value)) return "#b18a61";
    if (/银|铝|金属/.test(value)) return "#b7bcc2";
    if (/黑卡|黑纸/.test(value)) return "#34312e";
    if (/白卡|铜版|灰板|特种纸/.test(value)) return "#c7ad86";
    return "#bc9569";
  }

  function createProgram(gl, vertexSource, fragmentSource) {
    const compile = (type, source) => {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        const reason = gl.getShaderInfoLog(shader) || "未知着色器错误";
        gl.deleteShader(shader);
        throw new Error(`3D着色器编译失败：${reason}`);
      }
      return shader;
    };
    const vertex = compile(gl.VERTEX_SHADER, vertexSource);
    const fragment = compile(gl.FRAGMENT_SHADER, fragmentSource);
    const program = gl.createProgram();
    gl.attachShader(program, vertex);
    gl.attachShader(program, fragment);
    gl.linkProgram(program);
    gl.deleteShader(vertex);
    gl.deleteShader(fragment);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      const reason = gl.getProgramInfoLog(program) || "未知链接错误";
      gl.deleteProgram(program);
      throw new Error(`3D程序链接失败：${reason}`);
    }
    return program;
  }

  function resolveImage(source) {
    if (!source) return Promise.reject(new TypeError("请选择图片"));
    const imageTypes = [
      typeof root.HTMLImageElement === "function" ? root.HTMLImageElement : null,
      typeof root.HTMLCanvasElement === "function" ? root.HTMLCanvasElement : null,
      typeof root.ImageBitmap === "function" ? root.ImageBitmap : null,
      typeof root.OffscreenCanvas === "function" ? root.OffscreenCanvas : null
    ].filter(Boolean);
    if (imageTypes.some(Type => source instanceof Type)) return Promise.resolve(source);
    if (typeof Blob !== "undefined" && source instanceof Blob && typeof root.createImageBitmap === "function") {
      return root.createImageBitmap(source);
    }
    if ((typeof source === "string" || (typeof Blob !== "undefined" && source instanceof Blob)) && typeof root.Image === "function") {
      return new Promise((resolve, reject) => {
        const image = new root.Image();
        let objectUrl = "";
        if (typeof source === "string") {
          if (/^https?:/i.test(source)) image.crossOrigin = "anonymous";
          image.src = source;
        } else if (root.URL?.createObjectURL) {
          objectUrl = root.URL.createObjectURL(source);
          image.src = objectUrl;
        } else {
          reject(new Error("当前环境不能读取这张图片"));
          return;
        }
        image.onload = () => {
          if (objectUrl) root.URL.revokeObjectURL?.(objectUrl);
          resolve(image);
        };
        image.onerror = () => {
          if (objectUrl) root.URL.revokeObjectURL?.(objectUrl);
          reject(new Error("贴图读取失败；请使用本地 PNG/JPG 或允许跨域的同源图片"));
        };
      });
    }
    return Promise.reject(new TypeError("贴图必须是图片、Canvas、Blob 或可读取的图片地址"));
  }

  class WebGLRenderer {
    constructor(canvas, onContextLost) {
      this.canvas = canvas;
      this.gl = canvas.getContext("webgl", {
        alpha:true,
        antialias:true,
        depth:true,
        premultipliedAlpha:false,
        preserveDrawingBuffer:false
      }) || canvas.getContext("experimental-webgl");
      if (!this.gl) throw new Error("当前设备未提供 WebGL");
      this.onContextLost = onContextLost;
      this.contextLost = event => {
        event.preventDefault?.();
        this.onContextLost?.();
      };
      canvas.addEventListener("webglcontextlost", this.contextLost, false);
      const gl = this.gl;
      this.program = createProgram(gl, `
        attribute vec3 a_position;
        attribute vec2 a_texcoord;
        varying vec2 v_texcoord;
        void main() {
          gl_Position = vec4(a_position, 1.0);
          v_texcoord = a_texcoord;
        }
      `, `
        precision mediump float;
        varying vec2 v_texcoord;
        uniform vec4 u_color;
        uniform sampler2D u_texture;
        uniform float u_useTexture;
        float paperNoise(vec2 point) {
          return fract(sin(dot(point, vec2(12.9898, 78.233))) * 43758.5453);
        }
        void main() {
          vec4 textureColor = texture2D(u_texture, v_texcoord);
          vec4 base = mix(u_color, vec4(textureColor.rgb * u_color.rgb, textureColor.a * u_color.a), u_useTexture);
          float fineGrain = paperNoise(floor(v_texcoord * vec2(420.0, 320.0)));
          float broadGrain = paperNoise(floor(v_texcoord * vec2(76.0, 58.0)));
          float fibre = sin((v_texcoord.x * 263.0 + v_texcoord.y * 41.0) * 3.1415926);
          float roughness = (fineGrain - .5) * .055 + (broadGrain - .5) * .035 + fibre * .009;
          base.rgb *= 1.0 + roughness * (1.0 - u_useTexture * .55);
          base.rgb = mix(base.rgb, sqrt(max(base.rgb, vec3(0.0))), .035);
          gl_FragColor = base;
        }
      `);
      this.buffer = gl.createBuffer();
      this.positionLocation = gl.getAttribLocation(this.program, "a_position");
      this.textureLocation = gl.getAttribLocation(this.program, "a_texcoord");
      this.colorLocation = gl.getUniformLocation(this.program, "u_color");
      this.useTextureLocation = gl.getUniformLocation(this.program, "u_useTexture");
      this.samplerLocation = gl.getUniformLocation(this.program, "u_texture");
      this.textures = new Map();
      this.destroyed = false;
    }

    resize() {
      const ratio = clamp(root.devicePixelRatio || 1, 1, 2);
      const width = Math.max(1, Math.round(this.canvas.clientWidth * ratio));
      const height = Math.max(1, Math.round(this.canvas.clientHeight * ratio));
      if (this.canvas.width !== width || this.canvas.height !== height) {
        this.canvas.width = width;
        this.canvas.height = height;
      }
      this.gl.viewport(0, 0, width, height);
      return { width, height };
    }

    textureFor(face, inward = false) {
      return this.textures.get(face.id)
        || this.textures.get(face.artworkSide)
        || this.textures.get(inward ? "inside" : "outside")
        || this.textures.get("outside")
        || this.textures.get("all")
        || null;
    }

    render(model, state) {
      if (this.destroyed) return;
      const gl = this.gl;
      const viewport = this.resize();
      const scene = projectScene(model, state, viewport);
      gl.clearColor(0, 0, 0, 0);
      gl.clearDepth(1);
      gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
      gl.enable(gl.DEPTH_TEST);
      gl.depthFunc(gl.LEQUAL);
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.useProgram(this.program);
      gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
      gl.enableVertexAttribArray(this.positionLocation);
      gl.enableVertexAttribArray(this.textureLocation);
      gl.vertexAttribPointer(this.positionLocation, 3, gl.FLOAT, false, 20, 0);
      gl.vertexAttribPointer(this.textureLocation, 2, gl.FLOAT, false, 20, 12);
      gl.uniform1i(this.samplerLocation, 0);
      const color = parseColor(state.color);
      const opacity = clamp(state.opacity ?? .9, .18, 1);
      const sorted = opacity < .999 ? scene.slice().sort((a, b) => b.depth - a.depth) : scene;
      gl.depthMask(opacity >= .999);
      sorted.forEach(item => {
        const points = item.points;
        if (points.length < 3) return;
        const localPoints = Array.isArray(item.face.localPoints) && item.face.localPoints.length === points.length
          ? item.face.localPoints
          : points.map(point => [point.x, point.y, 0]);
        const xs = localPoints.map(point => finite(point[0]));
        const ys = localPoints.map(point => finite(point[1]));
        const minimumX = Math.min(...xs);
        const maximumX = Math.max(...xs);
        const minimumY = Math.min(...ys);
        const maximumY = Math.max(...ys);
        const spanX = Math.max(EPSILON, maximumX - minimumX);
        const spanY = Math.max(EPSILON, maximumY - minimumY);
        const vertices = [];
        points.forEach((point, index) => vertices.push(
          point.x,
          point.y,
          point.z,
          (xs[index] - minimumX) / spanX,
          1 - (ys[index] - minimumY) / spanY
        ));
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
        const faceColor = item.face.featureColor ? parseColor(item.face.featureColor) : color;
        gl.uniform4f(
          this.colorLocation,
          faceColor[0] * item.shade,
          faceColor[1] * item.shade,
          faceColor[2] * item.shade,
          opacity * item.visibility
        );
        const texture = this.textureFor(item.face, item.normal[2] < 0);
        gl.uniform1f(this.useTextureLocation, texture ? 1 : 0);
        if (texture) {
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D, texture);
        }
        gl.drawArrays(gl.TRIANGLE_FAN, 0, points.length);
      });
      gl.depthMask(true);
      gl.disable(gl.BLEND);
      gl.lineWidth(clamp((root.devicePixelRatio || 1) * 1.15, 1, 2));
      scene.filter(item => item.visibility > .08).forEach(item => {
        const points = item.points;
        if (points.length < 2) return;
        const vertices = [];
        points.forEach(point => vertices.push(point.x, point.y, point.z - .0008, 0, 0));
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
        gl.uniform4f(this.colorLocation, .17, .14, .11, .66);
        gl.uniform1f(this.useTextureLocation, 0);
        gl.drawArrays(gl.LINE_LOOP, 0, points.length);
      });
    }

    async setTexture(panelId, source) {
      if (!PANEL_IDS.has(panelId)) throw new RangeError("贴图位置必须是 front、back、left、right、top、bottom、outside、inside 或 all");
      const image = await resolveImage(source);
      if (this.destroyed) {
        image.close?.();
        throw new Error("3D预览已经关闭");
      }
      const gl = this.gl;
      const texture = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
      image.close?.();
      const previous = this.textures.get(panelId);
      if (previous) gl.deleteTexture(previous);
      this.textures.set(panelId, texture);
    }

    clearTexture(panelId) {
      const texture = this.textures.get(panelId);
      if (texture) this.gl.deleteTexture(texture);
      this.textures.delete(panelId);
    }

    destroy() {
      if (this.destroyed) return;
      this.destroyed = true;
      this.canvas.removeEventListener("webglcontextlost", this.contextLost, false);
      this.textures.forEach(texture => this.gl.deleteTexture(texture));
      this.textures.clear();
      this.gl.deleteBuffer(this.buffer);
      this.gl.deleteProgram(this.program);
    }
  }

  class Canvas2DRenderer {
    constructor(canvas) {
      this.canvas = canvas;
      this.context = canvas.getContext("2d");
      if (!this.context) throw new Error("当前设备无法创建预览画布");
      this.destroyed = false;
    }

    resize() {
      const ratio = clamp(root.devicePixelRatio || 1, 1, 2);
      const width = Math.max(1, Math.round(this.canvas.clientWidth * ratio));
      const height = Math.max(1, Math.round(this.canvas.clientHeight * ratio));
      if (this.canvas.width !== width || this.canvas.height !== height) {
        this.canvas.width = width;
        this.canvas.height = height;
      }
      return { width, height };
    }

    render(model, state) {
      if (this.destroyed) return;
      const context = this.context;
      const viewport = this.resize();
      const scene = projectScene(model, state, viewport).sort((first, second) => second.depth - first.depth);
      context.clearRect(0, 0, viewport.width, viewport.height);
      const color = parseColor(state.color).map(value => Math.round(value * 255));
      scene.forEach(item => {
        const points = item.points.map(point => ({
          x:(point.x + 1) * viewport.width / 2,
          y:(1 - point.y) * viewport.height / 2
        }));
        context.beginPath();
        context.moveTo(points[0].x, points[0].y);
        points.slice(1).forEach(point => context.lineTo(point.x, point.y));
        context.closePath();
        const faceColor = (item.face.featureColor ? parseColor(item.face.featureColor) : color.map(value => value / 255))
          .map(value => Math.round(value * 255));
        const red = Math.round(faceColor[0] * item.shade);
        const green = Math.round(faceColor[1] * item.shade);
        const blue = Math.round(faceColor[2] * item.shade);
        const alpha = clamp(state.opacity ?? .9, .18, 1) * item.visibility;
        const gradient = context.createLinearGradient(
          Math.min(...points.map(point => point.x)),
          Math.min(...points.map(point => point.y)),
          Math.max(...points.map(point => point.x)),
          Math.max(...points.map(point => point.y))
        );
        gradient.addColorStop(0, `rgba(${Math.min(255, red + 15)},${Math.min(255, green + 13)},${Math.min(255, blue + 9)},${alpha})`);
        gradient.addColorStop(.58, `rgba(${red},${green},${blue},${alpha})`);
        gradient.addColorStop(1, `rgba(${Math.max(0, red - 14)},${Math.max(0, green - 13)},${Math.max(0, blue - 11)},${alpha})`);
        context.fillStyle = gradient;
        context.fill();
        context.strokeStyle = "rgba(72,54,38,.7)";
        context.lineWidth = Math.max(1, root.devicePixelRatio || 1);
        context.stroke();
      });
    }

    setTexture() {
      return Promise.reject(new Error("当前设备正在使用2D兼容预览，不能显示贴图；刀板和尺寸数据不受影响"));
    }

    clearTexture() {}
    destroy() { this.destroyed = true; }
  }

  function createInteractionController(target, state, onChange) {
    let dragging = false;
    let pointerId = null;
    let previousX = 0;
    let previousY = 0;
    const handlers = {
      pointerdown(event) {
        if (event.button !== undefined && event.button !== 0) return;
        dragging = true;
        pointerId = event.pointerId ?? 1;
        previousX = finite(event.clientX);
        previousY = finite(event.clientY);
        target.setPointerCapture?.(pointerId);
      },
      pointermove(event) {
        if (!dragging || (event.pointerId !== undefined && pointerId !== event.pointerId)) return;
        const nextX = finite(event.clientX);
        const nextY = finite(event.clientY);
        state.yaw += (nextX - previousX) * .008;
        state.pitch = clamp(state.pitch + (nextY - previousY) * .008, -1.45, 1.45);
        previousX = nextX;
        previousY = nextY;
        onChange();
      },
      pointerup(event) {
        if (!dragging || (event.pointerId !== undefined && pointerId !== event.pointerId)) return;
        dragging = false;
        target.releasePointerCapture?.(pointerId);
        pointerId = null;
      },
      pointercancel() {
        dragging = false;
        pointerId = null;
      },
      wheel(event) {
        event.preventDefault?.();
        const direction = finite(event.deltaY) > 0 ? .9 : 1.1;
        state.zoom = clamp(state.zoom * direction, .35, 3.5);
        onChange();
      }
    };
    const options = { passive:false };
    target.addEventListener("pointerdown", handlers.pointerdown);
    target.addEventListener("pointermove", handlers.pointermove);
    target.addEventListener("pointerup", handlers.pointerup);
    target.addEventListener("pointercancel", handlers.pointercancel);
    target.addEventListener("wheel", handlers.wheel, options);
    return {
      handlers,
      destroy() {
        target.removeEventListener("pointerdown", handlers.pointerdown);
        target.removeEventListener("pointermove", handlers.pointermove);
        target.removeEventListener("pointerup", handlers.pointerup);
        target.removeEventListener("pointercancel", handlers.pointercancel);
        target.removeEventListener("wheel", handlers.wheel, options);
      }
    };
  }

  function createViewer(container, dieline, options = {}) {
    if (!container || typeof container.appendChild !== "function") throw new TypeError("请提供3D预览容器");
    if (typeof document === "undefined" || !document.createElement) throw new Error("3D预览只能在浏览器页面中运行");
    let model = buildFoldModel(dieline, options);
    let destroyed = false;
    let animationFrame = null;
    let resizeObserver = null;
    const automaticMaterialColor = !options.color;
    const state = {
      fold:clamp(options.fold ?? 1, 0, 1),
      yaw:finite(options.yaw, -.68),
      pitch:finite(options.pitch, .38),
      zoom:clamp(options.zoom ?? .96, .35, 3.5),
      color:String(options.color || materialPreviewColor(model.material)),
      opacity:clamp(options.opacity ?? 1, .18, 1)
    };

    const shell = document.createElement("section");
    shell.className = "jl-dieline-3d";
    shell.setAttribute("aria-label", "刀版实时3D结构预览");
    shell.innerHTML = `
      <div class="jl-dieline-3d__head">
        <div><strong>实时 3D 结构预览</strong><span data-role="model-name"></span></div>
        <span class="jl-dieline-3d__badge">沟通预览 · 非生产刀线</span>
      </div>
      <div class="jl-dieline-3d__stage">
        <div class="jl-dieline-3d__ground-shadow" aria-hidden="true"></div>
        <canvas tabindex="0" aria-label="拖动旋转，滚轮缩放刀版3D预览"></canvas>
        <div class="jl-dieline-3d__scene-mark" aria-hidden="true"><strong>3D</strong><span>↻</span></div>
        <div class="jl-dieline-3d__hint">拖动旋转 · 滚轮缩放</div>
        <label class="jl-dieline-3d__fold-dock">
          <span>展开</span>
          <input data-role="fold" type="range" min="0" max="100" step="1" aria-label="展开或闭合盒型">
          <span>闭合</span>
          <output data-role="fold-value">100%</output>
        </label>
      </div>
      <div class="jl-dieline-3d__controls">
        <label><span>纸张颜色</span><input data-role="color" type="color" aria-label="纸张颜色"></label>
        <label><span>材质透明度</span><input data-role="opacity" type="range" min="18" max="100" step="1" aria-label="材质透明度"></label>
        <button data-role="reset" type="button">恢复产品视角</button>
      </div>
      <div class="jl-dieline-3d__status" data-role="status" role="status" aria-live="polite"></div>
      <p class="jl-dieline-3d__disclaimer">0% 展开显示本型号生产刀板，100% 显示对应成型结构；正式生产仍请下载并复核 SVG / PDF / DXF 刀线、公差与装配。</p>
    `;
    container.appendChild(shell);
    const canvas = shell.querySelector("canvas");
    const foldInput = shell.querySelector('[data-role="fold"]');
    const colorInput = shell.querySelector('[data-role="color"]');
    const opacityInput = shell.querySelector('[data-role="opacity"]');
    const foldOutput = shell.querySelector('[data-role="fold-value"]');
    const resetButton = shell.querySelector('[data-role="reset"]');
    const status = shell.querySelector('[data-role="status"]');
    const modelName = shell.querySelector('[data-role="model-name"]');
    const badge = shell.querySelector(".jl-dieline-3d__badge");
    foldInput.value = Math.round(state.fold * 100);
    foldOutput.value = `${Math.round(state.fold * 100)}%`;
    colorInput.value = /^#[0-9a-f]{6}$/i.test(state.color) ? state.color : "#bc9569";
    opacityInput.value = Math.round(state.opacity * 100);

    let renderer;
    let mode = "webgl";
    const useFallback = reason => {
      renderer?.destroy?.();
      mode = "2d";
      try {
        renderer = new Canvas2DRenderer(canvas);
        status.textContent = `当前设备未启用 WebGL，已切换到 2D 交互预览。${reason || ""}`;
        status.dataset.kind = "warning";
      } catch (_) {
        mode = "text";
        renderer = null;
        status.textContent = "当前设备无法创建图形画布；请查看2D刀版并下载生产格式。";
        status.dataset.kind = "error";
      }
      invalidate();
    };
    try {
      if (options.force2D) throw new Error("已请求2D兼容模式");
      renderer = new WebGLRenderer(canvas, () => useFallback("3D上下文已丢失。"));
      status.textContent = "3D 产品预览已就绪；可拖动查看各面，并用展开/闭合滑杆核对结构。";
      status.dataset.kind = "ok";
    } catch (error) {
      useFallback(error.message);
    }

    function render() {
      animationFrame = null;
      if (!destroyed && renderer) renderer.render(model, state);
    }

    function invalidate() {
      if (destroyed || animationFrame !== null) return;
      const request = root.requestAnimationFrame || (callback => setTimeout(callback, 16));
      animationFrame = request(render);
    }

    function resetView() {
      state.yaw = -.68;
      state.pitch = .38;
      state.zoom = .96;
      invalidate();
    }

    const interaction = createInteractionController(canvas, state, invalidate);
    const listeners = [
      [foldInput, "input", event => {
        state.fold = clamp(event.target.value / 100, 0, 1);
        foldOutput.value = `${Math.round(state.fold * 100)}%`;
        invalidate();
      }],
      [colorInput, "input", event => { state.color = event.target.value; invalidate(); }],
      [opacityInput, "input", event => { state.opacity = clamp(event.target.value / 100, .18, 1); invalidate(); }],
      [resetButton, "click", resetView],
      [canvas, "dblclick", resetView]
    ];
    listeners.forEach(([target, type, handler]) => target.addEventListener(type, handler));
    if (typeof root.ResizeObserver === "function") {
      resizeObserver = new root.ResizeObserver(invalidate);
      resizeObserver.observe(canvas);
    } else {
      root.addEventListener?.("resize", invalidate);
    }

    function setDieline(nextDieline) {
      const nextModel = buildFoldModel(nextDieline, options);
      const expectedTemplateId = String(nextDieline?.options?.definition?.id || "");
      const expectedSignature = String(nextDieline?.geometrySignature || "");
      const expectedBounds = JSON.stringify(nextDieline?.geometryBounds || {});
      if (expectedTemplateId && nextModel.templateId !== expectedTemplateId) {
        throw new Error("3D 结构与当前刀板盒型不一致，已停止显示旧模型");
      }
      if (expectedSignature && nextModel.geometrySignature !== expectedSignature) {
        throw new Error("3D 结构与当前刀板几何签名不一致，已停止显示旧模型");
      }
      if (expectedBounds !== "{}" && JSON.stringify(nextModel.geometryBounds || {}) !== expectedBounds) {
        throw new Error("3D 结构与当前刀板边界不一致，已停止显示旧模型");
      }
      // Assign only after every identity check passes. A failed rapid switch
      // therefore cannot replace the current model with a partially stale one.
      model = nextModel;
      state.fold = 1;
      foldInput.value = "100";
      foldOutput.value = "100%";
      state.zoom = .96;
      if (automaticMaterialColor) {
        state.color = materialPreviewColor(model.material);
        colorInput.value = state.color;
      }
      updateModelCopy();
      invalidate();
      return model;
    }

    function updateModelCopy() {
      modelName.textContent = `${model.name}${model.code ? ` · ${model.code}` : ""}`;
      if (model.structureLinked) {
        badge.textContent = "刀板同源 · 实时成型";
        badge.dataset.kind = "exact";
        status.textContent = "展开端显示本型号生产刀板，闭合端显示对应产品效果；切换盒型和尺寸后会同步重建全部面。";
        status.dataset.kind = "ok";
      } else {
        badge.textContent = "外观预览 · 非生产结构";
        badge.dataset.kind = "approximate";
        status.textContent = "当前盒型尚未建立刀板同源 3D，只显示外观沟通预览；请勿据此判断生产结构。";
        status.dataset.kind = "warning";
      }
    }

    updateModelCopy();
    invalidate();

    return {
      get mode() { return mode; },
      get model() { return model; },
      getState() { return { ...state }; },
      setDieline,
      setFold(value) {
        state.fold = clamp(value, 0, 1);
        foldInput.value = Math.round(state.fold * 100);
        foldOutput.value = `${Math.round(state.fold * 100)}%`;
        invalidate();
      },
      setMaterial(value = {}) {
        if (value.color) {
          state.color = /^#[0-9a-f]{6}$/i.test(value.color) ? value.color : state.color;
          colorInput.value = state.color;
        }
        if (value.opacity !== undefined) {
          state.opacity = clamp(value.opacity, .18, 1);
          opacityInput.value = Math.round(state.opacity * 100);
        }
        invalidate();
      },
      async setTexture(panelId, source) {
        await renderer?.setTexture(panelId, source);
        status.textContent = `${panelId === "front" ? "正面" : panelId === "back" ? "背面" : panelId}贴图已更新。`;
        status.dataset.kind = "ok";
        invalidate();
      },
      async setArtwork(value = {}) {
        const tasks = [];
        if (value.front) tasks.push(this.setTexture("front", value.front));
        if (value.back) tasks.push(this.setTexture("back", value.back));
        if (value.outside) tasks.push(this.setTexture("outside", value.outside));
        if (value.inside) tasks.push(this.setTexture("inside", value.inside));
        await Promise.all(tasks);
      },
      clearTexture(panelId) {
        renderer?.clearTexture(panelId);
        invalidate();
      },
      resetView,
      destroy() {
        if (destroyed) return;
        destroyed = true;
        interaction.destroy();
        listeners.forEach(([target, type, handler]) => target.removeEventListener(type, handler));
        resizeObserver?.disconnect();
        if (!resizeObserver) root.removeEventListener?.("resize", invalidate);
        if (animationFrame !== null) {
          const cancel = root.cancelAnimationFrame || clearTimeout;
          cancel(animationFrame);
          animationFrame = null;
        }
        renderer?.destroy();
        renderer = null;
        shell.remove();
      }
    };
  }

  return {
    VERSION,
    buildFoldModel,
    panelVertices,
    projectScene,
    createInteractionController,
    createViewer,
    precisionNotice:"实时3D仅用于结构沟通；生产导出继续使用 JianglaiDieLayout 的 SVG/PDF/DXF。"
  };
});
