From f71000befd50feb044431af250654e19b84668a5 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sun, 11 Oct 2026 10:20:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B2=E6=9F=93=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=B8=89=E7=BB=B4=E6=B8=B2=E6=9F=93=E3=80=81?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=8F=8A=E7=89=88=E6=9C=AC=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 4 +- go.sum | 8 +- ofdgo_editor_font.go | 43 ++- ofdgo_editor_xml.go | 24 +- ofdgo_font_backend.go | 11 +- ofdgo_font_cff2.go | 112 +------- ofdgo_font_cff_cid_subset.go | 239 ++++++++++++++++ ofdgo_font_cff_compact.go | 339 +++++++++++++++++++++++ ofdgo_font_cff_subset.go | 156 ++++++++--- ofdgo_font_resource_sfnt.go | 27 +- ofdgo_font_shape.go | 70 ++++- ofdgo_font_type2.go | 4 + ofdgo_pdf.go | 15 +- ofdgo_pdf_3d.go | 20 +- ofdgo_pdf_font.go | 75 +++-- ofdgo_pdf_text_replacement.go | 55 +++- ofdgo_reader.go | 26 ++ ofdgo_u3d.go | 87 +++++- ofdgo_u3d_animation.go | 176 ++++++++++++ ofdgo_u3d_bits.go | 21 ++ ofdgo_u3d_light.go | 22 +- ofdgo_u3d_line.go | 43 ++- ofdgo_u3d_lineset.go | 20 +- ofdgo_u3d_mapping.go | 141 ++++++++++ ofdgo_u3d_mesh.go | 40 +-- ofdgo_u3d_mode.go | 39 ++- ofdgo_u3d_motion.go | 188 +++++++++++++ ofdgo_u3d_outline.go | 3 +- ofdgo_u3d_playback.go | 329 ++++++++++++++++++++++ ofdgo_u3d_render.go | 124 ++++++--- ofdgo_u3d_resource_reader.go | 141 ++++++++++ ofdgo_u3d_sample.go | 215 +++++++++++++++ ofdgo_u3d_scene.go | 102 ++++--- ofdgo_u3d_section.go | 9 +- ofdgo_u3d_shader.go | 303 ++++++++++++++++++++ ofdgo_u3d_skeleton.go | 103 +++++++ ofdgo_u3d_skin.go | 505 ++++++++++++++++++++++++++++++++++ ofdgo_u3d_state.go | 69 ++++- ofdgo_u3d_texture.go | 179 ++++++++++++ ofdgo_u3d_texture_decode.go | 281 +++++++++++++++++++ ofdgo_u3d_triangle.go | 74 +++-- ofdgo_u3d_view_render.go | 103 +++++++ ofdgo_u3d_weights.go | 180 ++++++++++++ ofdgo_version_edit.go | 34 ++- ofdgo_writer.go | 46 +++- 45 files changed, 4383 insertions(+), 422 deletions(-) create mode 100644 ofdgo_font_cff_cid_subset.go create mode 100644 ofdgo_font_cff_compact.go create mode 100644 ofdgo_u3d_animation.go create mode 100644 ofdgo_u3d_mapping.go create mode 100644 ofdgo_u3d_motion.go create mode 100644 ofdgo_u3d_playback.go create mode 100644 ofdgo_u3d_resource_reader.go create mode 100644 ofdgo_u3d_sample.go create mode 100644 ofdgo_u3d_shader.go create mode 100644 ofdgo_u3d_skeleton.go create mode 100644 ofdgo_u3d_skin.go create mode 100644 ofdgo_u3d_texture.go create mode 100644 ofdgo_u3d_texture_decode.go create mode 100644 ofdgo_u3d_view_render.go create mode 100644 ofdgo_u3d_weights.go diff --git a/go.mod b/go.mod index fa3e18b..2d9badb 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ require ( github.com/emmansun/gmsm v0.45.0 github.com/go-text/typesetting v0.3.5 github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c - github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 + github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e github.com/xiaoqidun/jbig2 v1.1.4 - github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7 + github.com/xiaoqidun/pdfgo v0.0.0-20261011021850-35fe3f96d36f golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/text v0.43.0 diff --git a/go.sum b/go.sum index ff5c237..ddc588e 100644 --- a/go.sum +++ b/go.sum @@ -58,12 +58,12 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8 github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= -github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893 h1:jmQlBO7R+bcimOMs9jjyDWR1FqiPNGtZqA/V1WiYfPA= -github.com/xiaoqidun/j2kgo v0.0.0-20261009063910-4865a21ef893/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= +github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e h1:OuRcvG5g5AieBN3fNKo130Iu3f+6/ihiWSe8//QT1qg= +github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.4 h1:LF6J1si4JY+TszTh9+m4KAg9/ba0JjfXoGtQTQC2S8c= github.com/xiaoqidun/jbig2 v1.1.4/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA= -github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7 h1:VFrEkWzhLLgoMZ4FNH/uN/Mdfo/TSGKzIPjqCL4m3J4= -github.com/xiaoqidun/pdfgo v0.0.0-20261010065159-b32618b0bdd7/go.mod h1:fXz1emtF1YCpcIv+fglDZstjkHxjS2l3G4DVMgUzVnk= +github.com/xiaoqidun/pdfgo v0.0.0-20261011021850-35fe3f96d36f h1:m3yM1CzrHWm+LW84WWcA2Kr1Ux8K/Znrgd0XVxGEtIw= +github.com/xiaoqidun/pdfgo v0.0.0-20261011021850-35fe3f96d36f/go.mod h1:hkZn8gHOI94K0fE2ynbXaE8J5T0/jCvoTS/yroKgb+E= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index cdf1a18..2bb66ff 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -151,6 +151,30 @@ func (u *editorFontUsage) merge(other *editorFontUsage, unsafe bool) { } } +// editorObjectFontScan 判断对象是否需要扫描嵌套文字或复合资源 +// 入参: object 图形对象 +// 返回: bool 是否需要完整引用扫描 +func editorObjectFontScan(object GraphicObject) bool { + var clips *Clips + var fill, stroke *FillColor + switch object.Type { + case "CompositeObject", "CompositeGraphicUnit": + return true + case "TextObject", "Text": + clips = object.TextObject.Clips + fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor) + case "PathObject", "Path": + clips = object.PathObject.Clips + fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor) + case "ImageObject": + clips = object.ImageObject.Clips + if object.ImageObject.Border != nil { + stroke = (*FillColor)(object.ImageObject.Border.BorderColor) + } + } + return clips != nil || fill != nil && fill.Pattern != nil || stroke != nil && stroke.Pattern != nil +} + // subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,保留完整编辑字体 // 入参: progress 保存进度回调 // 返回: map[string][]byte 包内字体子集, error 错误信息 @@ -181,11 +205,17 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) return nil, progress.report("fonts", 0, 0) } refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} - composite := false + nested := false + for _, resource := range e.resources { + if resource.draw != nil { + nested = true + break + } + } if e.source != nil { for _, name := range e.annotationFiles() { if data, changed := e.source.reader.files[name]; changed { - composite = true + nested = true if _, safe := refs.scan(bytes.NewReader(data), name); !safe { return nil, nil } @@ -199,8 +229,8 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } for _, layer := range page.Content.Layer { for _, object := range layer.Objects { - if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { - composite = true + if editorObjectFontScan(object) { + nested = true origin := e.objectOrigin(editorObjectID(object)) var data []byte var err error @@ -218,6 +248,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) if _, safe := refs.scan(bytes.NewReader(data), "Page.xml"); !safe { return nil, nil } + continue } if object.Type != "TextObject" { continue @@ -246,10 +277,10 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } } } - if composite { + if nested { _, _, _, files := e.usedResources() for i, resource := range e.resources { - if resource.composite == "" || !slices.Contains(files, resource.name) { + if resource.definition() == "" || !slices.Contains(files, resource.name) { continue } if err := progress.report("fonts", len(e.pages)+i, total); err != nil { diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index 718d44f..63aeaea 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -16,6 +16,7 @@ package ofdgo import ( "bytes" + "context" "encoding/xml" "fmt" "io" @@ -42,10 +43,26 @@ type editorXMLPatch struct { // 入参: data XML原文 // 返回: *editorXML 根节点, error 错误信息 func parseEditorXML(data []byte) (*editorXML, error) { - d := xml.NewDecoder(bytes.NewReader(data)) + return parseEditorXMLContext(nil, data) +} + +// parseEditorXMLContext 解析XML节点位置并响应取消 +// 入参: ctx 取消上下文,可为nil, data XML原文 +// 返回: *editorXML 根节点, error 解析或取消错误 +func parseEditorXMLContext(ctx context.Context, data []byte) (*editorXML, error) { + var input io.Reader = bytes.NewReader(data) + if ctx != nil { + input = imageInput{ReadCloser: io.NopCloser(input), context: ctx} + } + d := xml.NewDecoder(input) var root *editorXML var stack []*editorXML for { + if ctx != nil { + if err := ctx.Err(); err != nil { + return nil, err + } + } start := int(d.InputOffset()) token, err := d.Token() if err == io.EOF { @@ -131,7 +148,12 @@ func editorPatchXML(data []byte, patches []editorXMLPatch) []byte { } return a.end - b.end }) + size := len(data) + for _, patch := range patches { + size += len(patch.data) - (patch.end - patch.start) + } var result bytes.Buffer + result.Grow(size) position := 0 for _, patch := range patches { result.Write(data[position:patch.start]) diff --git a/ofdgo_font_backend.go b/ofdgo_font_backend.go index 99cee9d..c1fa8b5 100644 --- a/ofdgo_font_backend.go +++ b/ofdgo_font_backend.go @@ -86,8 +86,17 @@ type FontShaperOptions interface { ShapeTextWithOptions(value string, size float64, options TextShapeOptions) ([]ShapedGlyph, error) } +// FontShaperRun 保留完整单行原文的上下文,仅塑形指定区间,不执行字体回退 +// start和end为原文的符文索引,区间左闭右开,返回Cluster仍使用原文索引 +// 调用方负责方向和脚本分段,options.Bidi须为false;坐标相对当前区间的基线起点 +// value为已校验的原文符文序列,调用期间不得修改,返回结果不引用该切片 +type FontShaperRun interface { + FontShaperOptions + ShapeTextRun(value []rune, start, end int, size float64, options TextShapeOptions) ([]ShapedGlyph, error) +} + // ShapedGlyph 保存塑形后的字形、原文簇和基线定位 -// Bidi模式下VisualOrder为簇从左到右的零起始顺序,同簇字形共用顺序 +// VisualOrder为簇从左到右的零起始顺序,同簇字形共用顺序 type ShapedGlyph struct { Glyph uint16 Cluster int diff --git a/ofdgo_font_cff2.go b/ofdgo_font_cff2.go index 723d05c..78bb04d 100644 --- a/ofdgo_font_cff2.go +++ b/ofdgo_font_cff2.go @@ -18,7 +18,6 @@ import ( "bytes" "encoding/binary" "fmt" - "maps" "math" "github.com/go-text/typesetting/font/cff" @@ -172,10 +171,11 @@ func staticCIDFont(table map[string][]byte, chars [][]byte) ([]byte, error) { } names := encodeCFFIndex([][]byte{[]byte(name)}) strings := encodeCFFIndex([][]byte{[]byte("Adobe"), []byte("Identity")}) - charset := []byte{0} + cids := make([]int, len(chars)) for gid := 1; gid < len(chars); gid++ { - charset = binary.BigEndian.AppendUint16(charset, uint16(gid)) + cids[gid] = gid } + charset := encodeCFFSubsetCharset(cids) selects := []byte{3, 0, 1, 0, 0, 0} selects = binary.BigEndian.AppendUint16(selects, uint16(len(chars))) fonts := encodeCFFIndex([][]byte{encodeCFFDict(cffDict{18: {0, 0}})}) @@ -203,109 +203,3 @@ func staticCIDFont(table map[string][]byte, chars [][]byte) ([]byte, error) { } return nil, fmt.Errorf("CFF offsets did not converge") } - -// staticCFFPrograms 读取无子程序和私有字典的CID字形,其他CFF保留原有保存策略 -// 入参: sfnt 已解析字体 -// 返回: [][]byte 独立字形程序,不符合条件时为空 -func staticCFFPrograms(sfnt *sfntFont) [][]byte { - data := sfnt.Tables["CFF "] - if len(data) < 4 { - return nil - } - _, end, err := readType2Index(data, int(data[2])) - if err != nil { - return nil - } - tops, end, err := readType2Index(data, end) - if err != nil || len(tops) != 1 { - return nil - } - _, end, err = readType2Index(data, end) - if err != nil { - return nil - } - globals, _, err := readType2Index(data, end) - if err != nil || len(globals) != 0 { - return nil - } - dict, err := readCFFDict(tops[0]) - if err != nil || len(dict[1230]) != 3 { - return nil - } - matrix, err := readCFFMatrix(dict, [6]float64{.001, 0, 0, .001, 0, 0}) - if err != nil || matrix[0] != matrix[3] || matrix[1] != 0 || matrix[2] != 0 || matrix[4] != 0 || matrix[5] != 0 || math.Abs(matrix[0]*float64(sfnt.UnitsPerEm())-1) > 1e-6 { - return nil - } - offset, err := cffDictOffset(data, dict, 1236) - if err != nil { - return nil - } - fds, _, err := readType2Index(data, offset) - if err != nil || len(fds) != 1 { - return nil - } - fd, err := readCFFDict(fds[0]) - if err != nil || len(fd) != 1 || len(fd[18]) != 2 || fd[18][0] != 0 { - return nil - } - offset, err = cffDictOffset(data, dict, 17) - if err != nil { - return nil - } - chars, _, err := readType2Index(data, offset) - if err != nil || len(chars) != int(sfnt.NumGlyphs()) { - return nil - } - return chars -} - -// subsetStaticCFFFont 裁剪静态CID字形并保留原编号、字符映射和度量 -// 入参: sfnt 已解析字体, glyphs 使用的字形 -// 返回: []byte 字体子集, error 裁剪错误 -func subsetStaticCFFFont(sfnt *sfntFont, glyphs []uint16) ([]byte, error) { - chars := staticCFFPrograms(sfnt) - if chars == nil { - return nil, fmt.Errorf("CFF font cannot be subset safely") - } - keep := make(map[uint16]bool, len(glyphs)+1) - keep[0] = true - for _, gid := range glyphs { - if int(gid) >= len(chars) { - return nil, fmt.Errorf("CFF glyph index out of range") - } - keep[gid] = true - } - empty := make(map[uint16][]byte) - for gid := range chars { - if !keep[uint16(gid)] { - width := sfnt.GlyphAdvance(uint16(gid)) - program := empty[width] - if program == nil { - var err error - program, err = cffOutlineProgram(nil, width) - if err != nil { - return nil, err - } - empty[width] = program - } - chars[gid] = program - } - } - table := maps.Clone(sfnt.Tables) - var err error - table["CFF "], err = staticCIDFont(table, chars) - if err != nil { - return nil, err - } - mapping := make(map[rune]uint16) - for gid := range keep { - for _, char := range sfnt.glyphCharacters(gid) { - mapping[char] = gid - } - } - table["cmap"] = buildCmapTable(sfnt.NumGlyphs(), mapping) - for _, tag := range []string{"DSIG", "GSUB", "GPOS", "GDEF"} { - delete(table, tag) - } - return serializeOTF(table) -} diff --git a/ofdgo_font_cff_cid_subset.go b/ofdgo_font_cff_cid_subset.go new file mode 100644 index 0000000..a181471 --- /dev/null +++ b/ofdgo_font_cff_cid_subset.go @@ -0,0 +1,239 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/binary" + "fmt" + "math" +) + +// cidSubsetFont 保存CID字体裁剪所需的字典、映射和执行资源 +type cidSubsetFont struct { + prefix []byte + strings [][]byte + top cffDict + dicts []cffDict + private []cffDict + fonts []*type2Font + selects []int + charset []byte +} + +// readCIDSubsetFont 读取CID字体的原始字典及逐字形归属,不合并字体字典 +// 入参: data CFF数据 +// 返回: *cidSubsetFont 裁剪资源, error 结构错误或不支持的字典 +func readCIDSubsetFont(data []byte) (*cidSubsetFont, error) { + if !isBareCFFData(data) { + return nil, fmt.Errorf("invalid CFF header") + } + names, end, err := readType2Index(data, int(data[2])) + if err != nil || len(names) != 1 { + return nil, fmt.Errorf("invalid CFF name index") + } + tops, end, err := readType2Index(data, end) + if err != nil || len(tops) != 1 { + return nil, fmt.Errorf("invalid CFF top index") + } + strings, end, err := readType2Index(data, end) + if err != nil { + return nil, err + } + globals, _, err := readType2Index(data, end) + if err != nil { + return nil, err + } + top, err := readCFFDict(tops[0]) + if err != nil { + return nil, err + } + ros := top[1230] + if len(ros) != 3 { + return nil, fmt.Errorf("invalid CFF ROS") + } + for index, value := range ros { + limit := float64(math.MaxInt32) + if index < 2 { + limit = float64(min(64999, 390+len(strings))) + } + if !finite(value) || value < 0 || value > limit || value != math.Trunc(value) { + return nil, fmt.Errorf("invalid CFF ROS operand") + } + } + for op := range top { + switch op { + case 0, 1, 2, 3, 4, 5, 13, 14, 15, 17, 1200, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1230, 1231, 1232, 1233, 1234, 1236, 1237: + default: + return nil, fmt.Errorf("unsupported CID CFF top operator %d", op) + } + } + if value, ok := top[1206]; ok && (len(value) != 1 || value[0] != 2) { + return nil, fmt.Errorf("unsupported CFF charstring type") + } + if _, err := readCFFMatrix(top, [6]float64{.001, 0, 0, .001, 0, 0}); err != nil { + return nil, err + } + charOffset, err := cffDictOffset(data, top, 17) + if err != nil { + return nil, err + } + chars, _, err := readType2Index(data, charOffset) + if err != nil || len(chars) == 0 { + return nil, fmt.Errorf("invalid CFF charstrings") + } + charsetOffset, err := cffDictOffset(data, top, 15) + if err != nil || charsetOffset <= 2 { + return nil, fmt.Errorf("invalid CID CFF charset offset") + } + cids, err := parseCFFCharset(data, charsetOffset, len(chars)) + if err != nil { + return nil, err + } + fdOffset, err := cffDictOffset(data, top, 1236) + if err != nil { + return nil, err + } + fds, _, err := readType2Index(data, fdOffset) + if err != nil || len(fds) == 0 || len(fds) > 256 { + return nil, fmt.Errorf("invalid CFF font dictionary count") + } + selectOffset, err := cffDictOffset(data, top, 1237) + if err != nil { + return nil, err + } + selects, err := readType2FDSelect(data, selectOffset, len(chars), len(fds)) + if err != nil { + return nil, err + } + if data[selectOffset] == 3 && binary.BigEndian.Uint16(data[selectOffset+3:]) != 0 { + return nil, fmt.Errorf("CFF FDSelect must include glyph zero") + } + font := &cidSubsetFont{ + prefix: append(bytes.Clone(data[:int(data[2])]), encodeCFFIndex(names)...), + strings: strings, top: top, selects: selects, charset: encodeCFFSubsetCharset(cids), + dicts: make([]cffDict, len(fds)), private: make([]cffDict, len(fds)), fonts: make([]*type2Font, len(fds)), + } + for index, fd := range fds { + dict, err := readCFFDict(fd) + if err != nil { + return nil, err + } + for op := range dict { + switch op { + case 18, 1207, 1238: + default: + return nil, fmt.Errorf("unsupported CFF font dictionary operator %d", op) + } + } + if _, err := readCFFMatrix(dict, [6]float64{1, 0, 0, 1, 0, 0}); err != nil { + return nil, err + } + if len(dict[18]) != 2 { + return nil, fmt.Errorf("missing CFF private dictionary") + } + resource := &type2Font{data: data, chars: chars, globals: globals, seed: 1, cid: true} + private, err := readType2Private(data, dict[18], resource) + if err != nil { + return nil, err + } + if _, err := encodeCFFSubsetPrivate(private); err != nil { + return nil, err + } + font.dicts[index], font.private[index], font.fonts[index] = dict, private, resource + } + return font, nil +} + +// write 重建CID字体偏移,保留CID映射、字体矩阵及各字典的私有参数 +// 入参: chars 裁剪后的字形程序 +// 返回: []byte CFF数据, error 偏移或字典错误 +func (f *cidSubsetFont) write(chars [][]byte) ([]byte, error) { + suffix := append(encodeCFFIndex(f.strings), encodeCFFIndex(f.fonts[0].globals)...) + selects := encodeCFFSubsetFDSelect(f.selects) + programs := encodeCFFIndex(chars) + private := make([][]byte, len(f.fonts)) + locals := make([][]byte, len(f.fonts)) + fds := make([][]byte, len(f.fonts)) + for index, font := range f.fonts { + var err error + private[index], err = encodeCFFSubsetPrivate(f.private[index]) + if err != nil { + return nil, err + } + if _, ok := f.private[index][19]; ok { + locals[index] = encodeCFFIndex(font.locals) + } + fds[index] = encodeCFFDict(f.dicts[index]) + } + for range 16 { + top := encodeCFFIndex([][]byte{encodeCFFDict(f.top)}) + array := encodeCFFIndex(fds) + offset := len(f.prefix) + len(top) + len(suffix) + f.top[15] = []float64{float64(offset)} + offset += len(f.charset) + f.top[1237] = []float64{float64(offset)} + offset += len(selects) + f.top[17] = []float64{float64(offset)} + offset += len(programs) + f.top[1236] = []float64{float64(offset)} + offset += len(array) + for index := range f.fonts { + f.dicts[index][18] = []float64{float64(len(private[index])), float64(offset)} + fds[index] = encodeCFFDict(f.dicts[index]) + offset += len(private[index]) + len(locals[index]) + } + updatedTop := encodeCFFIndex([][]byte{encodeCFFDict(f.top)}) + updatedArray := encodeCFFIndex(fds) + if len(updatedTop) != len(top) || len(updatedArray) != len(array) { + continue + } + result := make([]byte, 0, offset) + for _, part := range [][]byte{f.prefix, updatedTop, suffix, f.charset, selects, programs, updatedArray} { + result = append(result, part...) + } + for index := range f.fonts { + result = append(result, private[index]...) + result = append(result, locals[index]...) + } + return result, nil + } + return nil, fmt.Errorf("CID CFF offsets did not converge") +} + +// encodeCFFSubsetFDSelect 选择较短的逐字形或分段格式保存字体字典归属 +// 入参: selects 字形所属字典 +// 返回: []byte FDSelect数据 +func encodeCFFSubsetFDSelect(selects []int) []byte { + ranges := []byte{3, 0, 0} + count := 0 + for gid, fd := range selects { + if gid == 0 || fd != selects[gid-1] { + ranges = binary.BigEndian.AppendUint16(ranges, uint16(gid)) + ranges = append(ranges, byte(fd)) + count++ + } + } + binary.BigEndian.PutUint16(ranges[1:], uint16(count)) + ranges = binary.BigEndian.AppendUint16(ranges, uint16(len(selects))) + if len(ranges) < len(selects)+1 { + return ranges + } + result := make([]byte, len(selects)+1) + for gid, fd := range selects { + result[gid+1] = byte(fd) + } + return result +} diff --git a/ofdgo_font_cff_compact.go b/ofdgo_font_cff_compact.go new file mode 100644 index 0000000..4f8ec4e --- /dev/null +++ b/ofdgo_font_cff_compact.go @@ -0,0 +1,339 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/binary" + "fmt" + "slices" +) + +// compactCFFGlyphs 重排已裁剪字体的字形,同步字符集、编码和字体字典归属 +// 入参: data 已裁剪的CFF数据, keep 保留的原字形编号 +// 返回: []byte 紧凑CFF数据, []uint16 新编号对应的原编号, error 结构或编码错误 +func compactCFFGlyphs(data []byte, keep map[uint16]bool) ([]byte, []uint16, error) { + ids := []uint16{0} + for gid, used := range keep { + if used && gid != 0 { + ids = append(ids, gid) + } + } + slices.Sort(ids) + font, err := readType2Font(data) + if err == nil { + sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars)) + if !ok { + return nil, nil, fmt.Errorf("invalid CFF charset") + } + _, end, _ := readType2Index(data, int(data[2])) + tops, _, _ := readType2Index(data, end) + dict, _ := readCFFDict(tops[0]) + encoding, err := cffSubsetEncoding(data, dict) + if err != nil { + return nil, nil, err + } + if encoding != nil { + codes, err := readCFFSubsetEncoding(encoding, sids) + if err != nil { + return nil, nil, err + } + ordered := []uint16{0} + seen := map[uint16]bool{0: true} + for _, gid := range codes { + if keep[gid] && !seen[gid] { + ordered = append(ordered, gid) + seen[gid] = true + } + } + for _, gid := range ids { + if !seen[gid] { + ordered = append(ordered, gid) + } + } + ids = ordered + } + chars := make([][]byte, len(ids)) + for index, gid := range ids { + if int(gid) >= len(font.chars) { + return nil, nil, fmt.Errorf("CFF glyph index out of range") + } + chars[index] = font.chars[gid] + } + result, err := rebuildNamedCFF(data, font, chars, ids) + return result, ids, err + } + cid, err := readCIDSubsetFont(data) + if err != nil { + return nil, nil, err + } + cids, _, _, _, ok := getCFFCharsetInfo(data, len(cid.selects)) + if !ok { + return nil, nil, fmt.Errorf("invalid CID CFF charset") + } + chars := make([][]byte, len(ids)) + selected := make([]int, len(ids)) + selects := make([]int, len(ids)) + dicts, private, fonts := []cffDict{}, []cffDict{}, []*type2Font{} + remap := make(map[int]int) + for index, gid := range ids { + if int(gid) >= len(cid.selects) { + return nil, nil, fmt.Errorf("CFF glyph index out of range") + } + fd := cid.selects[gid] + mapped, exists := remap[fd] + if !exists { + mapped = len(fonts) + remap[fd] = mapped + dicts = append(dicts, cid.dicts[fd]) + private = append(private, cid.private[fd]) + fonts = append(fonts, cid.fonts[fd]) + } + chars[index], selected[index], selects[index] = cid.fonts[fd].chars[gid], cids[gid], mapped + } + cid.dicts, cid.private, cid.fonts = dicts, private, fonts + cid.charset, cid.selects = encodeCFFSubsetCharset(selected), selects + result, err := cid.write(chars) + return result, ids, err +} + +// compactCFFTables 重建按字形编号索引的度量和字符映射,不保留旧编号的布局表 +// 入参: font 原字体, cff 紧凑CFF数据, ids 新编号对应的原编号 +// 返回: []byte 独立OpenType字体, error 封装错误 +func compactCFFTables(font *sfntFont, cff []byte, ids []uint16) ([]byte, error) { + tables := make(map[string][]byte) + for _, tag := range []string{"head", "hhea", "maxp", "OS/2", "post", "name", "vhea", "gasp"} { + if data := font.Tables[tag]; data != nil { + tables[tag] = bytes.Clone(data) + } + } + var hmtx, vmtx []byte + mapping := make(map[rune]uint16) + for index, gid := range ids { + hmtx = binary.BigEndian.AppendUint16(hmtx, font.GlyphAdvance(gid)) + hmtx = binary.BigEndian.AppendUint16(hmtx, uint16(font.sideBearing(gid, false))) + if font.vertical != 0 { + vmtx = binary.BigEndian.AppendUint16(vmtx, font.GlyphVerticalAdvance(gid)) + vmtx = binary.BigEndian.AppendUint16(vmtx, uint16(font.sideBearing(gid, true))) + } + for _, char := range font.glyphCharacters(gid) { + mapping[char] = uint16(index) + } + } + tables["CFF "], tables["hmtx"] = cff, hmtx + tables["cmap"] = buildCmapTable(uint16(len(ids)), mapping) + binary.BigEndian.PutUint16(tables["hhea"][34:], uint16(len(ids))) + binary.BigEndian.PutUint16(tables["maxp"][4:], uint16(len(ids))) + if len(tables["post"]) >= 32 { + tables["post"] = tables["post"][:32] + binary.BigEndian.PutUint32(tables["post"], 0x30000) + } + if font.vertical != 0 { + tables["vmtx"] = vmtx + binary.BigEndian.PutUint16(tables["vhea"][34:], uint16(len(ids))) + } + if data := font.Tables["VORG"]; data != nil { + var err error + tables["VORG"], err = compactCFFVerticalOrigins(data, font.NumGlyphs(), ids) + if err != nil { + return nil, err + } + } + return serializeOTF(tables) +} + +// compactCFFVerticalOrigins 重排CFF字体的逐字形纵向原点,保留默认原点 +// 入参: data VORG表, count 原字形数, ids 新编号对应的原编号 +// 返回: []byte 重建的VORG表, error 表结构或记录顺序错误 +func compactCFFVerticalOrigins(data []byte, count uint16, ids []uint16) ([]byte, error) { + if len(data) < 8 || binary.BigEndian.Uint32(data) != 0x10000 { + return nil, fmt.Errorf("invalid CFF vertical origin header") + } + length := int(binary.BigEndian.Uint16(data[6:])) + if length > (len(data)-8)/4 { + return nil, fmt.Errorf("truncated CFF vertical origins") + } + origins := make(map[uint16]uint16, length) + previous := -1 + for index := range length { + pos := 8 + index*4 + gid := binary.BigEndian.Uint16(data[pos:]) + if gid >= count || int(gid) <= previous { + return nil, fmt.Errorf("invalid CFF vertical origin glyph") + } + origins[gid] = binary.BigEndian.Uint16(data[pos+2:]) + previous = int(gid) + } + result := bytes.Clone(data[:8]) + for index, gid := range ids { + if origin, ok := origins[gid]; ok { + result = binary.BigEndian.AppendUint16(result, uint16(index)) + result = binary.BigEndian.AppendUint16(result, origin) + } + } + binary.BigEndian.PutUint16(result[6:], uint16((len(result)-8)/4)) + return result, nil +} + +// encodeCFFSubsetCharset 选择最短的标准字符集格式,保持SID或CID及字形顺序 +// 入参: ids 按字形编号排列的SID或CID,包含.notdef +// 返回: []byte 字符集数据 +func encodeCFFSubsetCharset(ids []int) []byte { + sizes := [3]int{1 + 2*(len(ids)-1), 1, 1} + for first := 1; first < len(ids); { + end := first + 1 + for end < len(ids) && ids[end] == ids[end-1]+1 { + end++ + } + sizes[1] += 3 * ((end - first + 255) / 256) + sizes[2] += 4 + first = end + } + format := 0 + for index, size := range sizes { + if size < sizes[format] { + format = index + } + } + result := make([]byte, 1, sizes[format]) + result[0] = byte(format) + for first := 1; first < len(ids); { + end := first + 1 + if format != 0 { + limit := len(ids) + if format == 1 { + limit = min(limit, first+256) + } + for end < limit && ids[end] == ids[end-1]+1 { + end++ + } + } + result = binary.BigEndian.AppendUint16(result, uint16(ids[first])) + if format == 1 { + result = append(result, byte(end-first-1)) + } else if format == 2 { + result = binary.BigEndian.AppendUint16(result, uint16(end-first-1)) + } + first = end + } + return result +} + +// readCFFSubsetEncoding 解析自定义编码及补充映射,校验重复编码和字形归属 +// 入参: data 编码数据, sids 原字符集 +// 返回: [256]uint16 编码对应的字形编号, error 编码结构错误 +func readCFFSubsetEncoding(data []byte, sids []int) ([256]uint16, error) { + var codes [256]uint16 + if len(data) < 2 { + return codes, fmt.Errorf("truncated CFF encoding") + } + pos, gid := 2, 1 + for index := 0; index < int(data[1]); index++ { + if pos >= len(data) { + return codes, fmt.Errorf("truncated CFF encoding") + } + first, count := int(data[pos]), 1 + pos++ + switch data[0] & 0x7f { + case 0: + case 1: + if pos >= len(data) { + return codes, fmt.Errorf("truncated CFF encoding range") + } + count += int(data[pos]) + pos++ + default: + return codes, fmt.Errorf("invalid CFF encoding format") + } + if first+count > len(codes) || gid+count > len(sids) { + return codes, fmt.Errorf("CFF encoding range out of bounds") + } + for code := first; code < first+count; code++ { + if codes[code] != 0 { + return codes, fmt.Errorf("duplicate CFF encoding") + } + codes[code] = uint16(gid) + gid++ + } + } + if data[0]&0x80 != 0 { + if pos >= len(data) { + return codes, fmt.Errorf("truncated CFF encoding supplements") + } + count := int(data[pos]) + pos++ + if count > (len(data)-pos)/3 { + return codes, fmt.Errorf("truncated CFF encoding supplements") + } + for range count { + code, sid := data[pos], int(binary.BigEndian.Uint16(data[pos+1:])) + pos += 3 + mapped := slices.Index(sids[:gid], sid) + if codes[code] != 0 || mapped <= 0 { + return codes, fmt.Errorf("invalid CFF supplemental glyph") + } + codes[code] = uint16(mapped) + } + } + if data[0]&0x7f > 1 || pos != len(data) { + return codes, fmt.Errorf("invalid CFF encoding length or format") + } + return codes, nil +} + +// encodeCFFSubsetEncoding 按新字形顺序重建主编码及同字形的补充编码 +// 入参: codes 原编码映射, sids 原字符集, ids 新编号对应的原编号 +// 返回: []byte 编码数据, error 字形顺序不满足编码约束 +func encodeCFFSubsetEncoding(codes [256]uint16, sids []int, ids []uint16) ([]byte, error) { + var base, supplements []byte + encodings := make(map[uint16][]byte) + for code, gid := range codes { + if gid != 0 { + encodings[gid] = append(encodings[gid], byte(code)) + } + } + unencoded := false + for _, gid := range ids[1:] { + values := encodings[gid] + if len(values) == 0 { + unencoded = true + } else if unencoded { + return nil, fmt.Errorf("CFF encoded glyph follows unencoded glyph") + } else { + base = append(base, values[0]) + for _, code := range values[1:] { + supplements = append(supplements, code) + supplements = binary.BigEndian.AppendUint16(supplements, uint16(sids[gid])) + } + } + } + var result []byte + if len(base) == 256 { + for index, code := range base { + if int(code) != index { + return nil, fmt.Errorf("CFF encoding exceeds format zero capacity") + } + } + result = []byte{1, 1, 0, 255} + } else { + result = append([]byte{0, byte(len(base))}, base...) + } + if len(supplements) != 0 { + result[0] |= 0x80 + result = append(result, byte(len(supplements)/3)) + result = append(result, supplements...) + } + return result, nil +} diff --git a/ofdgo_font_cff_subset.go b/ofdgo_font_cff_subset.go index 7f69060..d840b0c 100644 --- a/ofdgo_font_cff_subset.go +++ b/ofdgo_font_cff_subset.go @@ -16,15 +16,14 @@ package ofdgo import ( "bytes" - "encoding/binary" "fmt" "maps" ) -// subsetNamedCFFFont 裁剪非CID的CFF字体,保留字形编号、组合组件和度量 -// 入参: sfnt 已解析字体, glyphs 使用的字形 +// subsetCFFFont 裁剪CFF字体,保留组合组件和度量,按需重排字形 +// 入参: sfnt 已解析字体, glyphs 使用的字形, preserve 是否保留原字形编号 // 返回: []byte 字体子集, error 裁剪错误 -func subsetNamedCFFFont(sfnt *sfntFont, glyphs []uint16) ([]byte, error) { +func subsetCFFFont(sfnt *sfntFont, glyphs []uint16, preserve bool) ([]byte, error) { keep := map[uint16]bool{0: true} for _, gid := range glyphs { if gid >= sfnt.NumGlyphs() { @@ -36,6 +35,11 @@ func subsetNamedCFFFont(sfnt *sfntFont, glyphs []uint16) ([]byte, error) { if err != nil { return bytes.Clone(sfnt.Write()), nil } + if !preserve { + if compact, ids, err := compactCFFGlyphs(data, keep); err == nil { + return compactCFFTables(sfnt, compact, ids) + } + } tables := maps.Clone(sfnt.Tables) tables["CFF "] = data mapping := make(map[rune]uint16) @@ -52,60 +56,106 @@ func subsetNamedCFFFont(sfnt *sfntFont, glyphs []uint16) ([]byte, error) { } // subsetCFFCharstrings 清空未引用的Type2轮廓及子程序,保留原字宽、提示和编号 -// 入参: data 非CID的CFF数据, keep 保留的字形,调用后包含组合组件 +// 入参: data CFF数据, keep 保留的字形,调用后包含组合组件 // 返回: []byte 重建的CFF数据, error 结构或依赖错误 func subsetCFFCharstrings(data []byte, keep map[uint16]bool) ([]byte, error) { font, err := readType2Font(data) + if err != nil { + cid, err := readCIDSubsetFont(data) + if err != nil { + return nil, err + } + chars, err := subsetType2Programs(cid.fonts, cid.selects, keep) + if err != nil { + return nil, err + } + return cid.write(chars) + } + chars, err := subsetType2Programs([]*type2Font{font}, nil, keep) if err != nil { return nil, err } + return rebuildNamedCFF(data, font, chars, nil) +} + +// subsetType2Programs 按字形所属字典收集子程序依赖并清空未使用的轮廓 +// 入参: fonts 执行资源, selects 字形所属字典,单字典时可为空, keep 保留的字形 +// 返回: [][]byte 字形程序, error 执行或字宽错误 +func subsetType2Programs(fonts []*type2Font, selects []int, keep map[uint16]bool) ([][]byte, error) { keep[0] = true - locals := make([]bool, len(font.locals)) - globals := make([]bool, len(font.globals)) + programs := fonts[0].chars + locals := make([][]bool, len(fonts)) + empty := make([]map[float64][]byte, len(fonts)) + for index, font := range fonts { + locals[index] = make([]bool, len(font.locals)) + empty[index] = make(map[float64][]byte) + } + globals := make([]bool, len(fonts[0].globals)) var stack [48]float64 for gid := range keep { - if int(gid) >= len(font.chars) { + if int(gid) >= len(programs) { return nil, fmt.Errorf("CFF glyph index out of range") } + fd := 0 + if selects != nil { + fd = selects[gid] + } + font := fonts[fd] steps := 0 - state := type2State{font: font, args: stack[:0], width: font.def, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep, localUsage: locals, globalUsage: globals} + state := type2State{font: font, args: stack[:0], width: font.def, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep, localUsage: locals[fd], globalUsage: globals} if _, err := state.run(font.chars[gid], 0); err != nil { return nil, err } } - chars := make([][]byte, len(font.chars)) - empty := make(map[float64][]byte) - for gid, program := range font.chars { + chars := make([][]byte, len(programs)) + for gid, program := range programs { if keep[uint16(gid)] { chars[gid] = program continue } + fd := 0 + if selects != nil { + fd = selects[gid] + } + font := fonts[fd] steps := 0 state := type2State{font: font, args: stack[:0], width: font.def, widthOnly: true, seed: font.seed + uint64(gid), steps: &steps, dependencies: keep} if _, err := state.run(program, 0); err != nil { return nil, err } - if encoded, ok := empty[state.width]; ok { + if encoded, ok := empty[fd][state.width]; ok { chars[gid] = encoded continue } var encoded bytes.Buffer if state.width != font.def { value := state.width - font.nominal - if err := encodeType2Number(&encoded, value); err != nil { - return nil, err + divisor := 1 + if value < -32768 || value >= 32768 { + divisor = 2 } - if parseNumberType2(encoded.Bytes(), 0) != value { + for range divisor { + if err := encodeType2Number(&encoded, value/float64(divisor)); err != nil { + return nil, err + } + } + if parseNumberType2(encoded.Bytes(), 0)*float64(divisor) != value { return nil, fmt.Errorf("CFF width cannot be represented exactly") } + if divisor == 2 { + encoded.Write([]byte{12, 10}) + } } encoded.WriteByte(14) chars[gid] = encoded.Bytes() - empty[state.width] = chars[gid] + empty[fd][state.width] = chars[gid] } - font.locals = subsetCFFSubroutines(font.locals, locals) - font.globals = subsetCFFSubroutines(font.globals, globals) - return rebuildNamedCFF(data, font, chars) + globalPrograms := subsetCFFSubroutines(fonts[0].globals, globals) + for index, font := range fonts { + font.locals = subsetCFFSubroutines(font.locals, locals[index]) + font.globals = globalPrograms + } + return chars, nil } // subsetCFFSubroutines 将未引用的子程序替换为空返回,保持调用编号及偏移基数 @@ -125,9 +175,9 @@ func subsetCFFSubroutines(programs [][]byte, used []bool) [][]byte { } // rebuildNamedCFF 重建非CID的CFF偏移,保留字符集、编码和私有提示参数 -// 入参: data 原始CFF数据, font 执行资源, chars 替换后的字形程序 +// 入参: data 原始CFF数据, font 执行资源, chars 替换后的字形程序, ids 字形重排顺序,nil时保留原编号 // 返回: []byte 重建的CFF数据, error 不支持的结构 -func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte) ([]byte, error) { +func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte, ids []uint16) ([]byte, error) { names, end, err := readType2Index(data, int(data[2])) if err != nil { return nil, err @@ -151,35 +201,39 @@ func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte) ([]byte, erro return nil, fmt.Errorf("unsupported CFF top operator %d", op) } } - sids, _, _, _, ok := getCFFCharsetInfo(data, len(chars)) + sids, _, _, _, ok := getCFFCharsetInfo(data, len(font.chars)) if !ok { return nil, fmt.Errorf("invalid CFF charset") } - charset := []byte{0} - for _, sid := range sids[1:] { - charset = binary.BigEndian.AppendUint16(charset, uint16(sid)) - } encoding, err := cffSubsetEncoding(data, dict) if err != nil { return nil, err } + if ids != nil { + if encoding != nil { + codes, err := readCFFSubsetEncoding(encoding, sids) + if err != nil { + return nil, err + } + encoding, err = encodeCFFSubsetEncoding(codes, sids, ids) + if err != nil { + return nil, err + } + } + selected := make([]int, len(ids)) + for index, gid := range ids { + selected[index] = sids[gid] + } + sids = selected + } + charset := encodeCFFSubsetCharset(sids) private, err := readType2Private(data, dict[18], &type2Font{}) if err != nil { return nil, err } - for op := range private { - switch op { - case 6, 7, 8, 9, 10, 11, 19, 20, 21, 1209, 1210, 1211, 1212, 1213, 1214, 1217, 1218, 1219: - default: - return nil, fmt.Errorf("unsupported CFF private operator %d", op) - } - } - privateData := encodeCFFDict(private) - if _, ok := private[19]; ok { - for private[19][0] != float64(len(privateData)) { - private[19] = []float64{float64(len(privateData))} - privateData = encodeCFFDict(private) - } + privateData, err := encodeCFFSubsetPrivate(private) + if err != nil { + return nil, err } prefix := bytes.Clone(data[:int(data[2])]) prefix = append(prefix, encodeCFFIndex(names)...) @@ -212,6 +266,28 @@ func rebuildNamedCFF(data []byte, font *type2Font, chars [][]byte) ([]byte, erro return nil, fmt.Errorf("CFF dictionary offsets did not converge") } +// encodeCFFSubsetPrivate 保留私有字典参数,将局部子程序安排在字典之后 +// 入参: private 私有字典 +// 返回: []byte 字典数据, error 不支持的参数或偏移错误 +func encodeCFFSubsetPrivate(private cffDict) ([]byte, error) { + for op := range private { + switch op { + case 6, 7, 8, 9, 10, 11, 19, 20, 21, 1209, 1210, 1211, 1212, 1213, 1214, 1217, 1218, 1219: + default: + return nil, fmt.Errorf("unsupported CFF private operator %d", op) + } + } + for range 16 { + data := encodeCFFDict(private) + value, ok := private[19] + if !ok || len(value) == 1 && value[0] == float64(len(data)) { + return data, nil + } + private[19] = []float64{float64(len(data))} + } + return nil, fmt.Errorf("CFF private offset did not converge") +} + // cffSubsetEncoding 读取自定义CFF编码及补充映射,预定义编码无需复制 // 入参: data CFF数据, dict 顶层字典 // 返回: []byte 编码数据, error 编码格式错误 diff --git a/ofdgo_font_resource_sfnt.go b/ofdgo_font_resource_sfnt.go index 12cfdab..dc4da81 100644 --- a/ofdgo_font_resource_sfnt.go +++ b/ofdgo_font_resource_sfnt.go @@ -102,7 +102,11 @@ func sfntSubsettable(sfnt *sfntFont) bool { return false } font, err := readType2Font(sfnt.Tables["CFF "]) - return err == nil && len(font.chars) == int(sfnt.NumGlyphs()) || staticCFFPrograms(sfnt) != nil + if err == nil { + return len(font.chars) == int(sfnt.NumGlyphs()) + } + cid, err := readCIDSubsetFont(sfnt.Tables["CFF "]) + return err == nil && len(cid.fonts[0].chars) == int(sfnt.NumGlyphs()) } return sfnt.IsTrueType && (sfnt.Tables["fvar"] == nil || variableSFNTSubsettable(sfnt)) } @@ -166,13 +170,14 @@ func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) { return bytes.Clone(data), nil } if sfnt.IsCFF { - if font, err := readType2Font(sfnt.Tables["CFF "]); err == nil { - if len(font.chars) != int(sfnt.NumGlyphs()) { - return nil, fmt.Errorf("CFF glyph count does not match maxp") - } - return subsetNamedCFFFont(sfnt, glyphs) + count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "]) + if err != nil { + return nil, err } - return subsetStaticCFFFont(sfnt, glyphs) + if count != int(sfnt.NumGlyphs()) { + return nil, fmt.Errorf("CFF glyph count does not match maxp") + } + return subsetCFFFont(sfnt, glyphs, mapped) } if sfnt.Tables["fvar"] != nil { return subsetVariableSFNTFont(sfnt, glyphs) @@ -203,7 +208,7 @@ func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) { return subsetCompactSFNTFont(sfnt, glyphs) } -// subsetSourceSFNTFont 裁剪静态TrueType或非CID的CFF原有字体,保留字形编号、组合依赖、度量和提示指令 +// subsetSourceSFNTFont 裁剪静态TrueType或CFF原有字体,保留字形编号、组合依赖、度量和提示指令 // 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复 // 入参: data 字体数据, usage 全包用字记录 // 返回: []byte 更小的字体子集,无确定收益时为空 @@ -217,7 +222,7 @@ func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte { } for tag := range sfnt.Tables { switch tag { - case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "CFF ", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM": + case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "CFF ", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "VORG", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM": default: return nil } @@ -250,8 +255,8 @@ func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte { } } if sfnt.IsCFF { - font, err := readType2Font(sfnt.Tables["CFF "]) - if err != nil || len(font.chars) != int(sfnt.NumGlyphs()) { + count, err := parseCFFAndCountGlyphs(sfnt.Tables["CFF "]) + if err != nil || count != int(sfnt.NumGlyphs()) { return nil } cff, err := subsetCFFCharstrings(sfnt.Tables["CFF "], glyphs) diff --git a/ofdgo_font_shape.go b/ofdgo_font_shape.go index a170f3a..8f10612 100644 --- a/ofdgo_font_shape.go +++ b/ofdgo_font_shape.go @@ -35,6 +35,9 @@ import ( xlanguage "golang.org/x/text/language" ) +// fontShapingBufferLimit 限制字体实例长期保留的字形缓冲容量 +const fontShapingBufferLimit = 4096 + // fontShaper 使用字体度量和原始数据提供可配置塑形,不依赖绘图后端 type fontShaper struct { metrics FontMetrics @@ -61,8 +64,32 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te if !rasterPositive(size) || !utf8.ValidString(value) { return nil, fmt.Errorf("invalid shaping text or size") } - for _, char := range value { - if unicode.IsControl(char) || char == '\u2028' || char == '\u2029' { + runes := []rune(value) + return f.shapeTextRun(runes, 0, len(runes), size, options) +} + +// ShapeTextRun 利用原文上下文塑形指定区间,保留原文簇索引 +// 入参: value 已校验的原文符文, start 起始符文索引, end 结束符文索引, size 毫米字号, options 已分段的塑形选项 +// 返回: []ShapedGlyph 原文簇索引及区间内定位字形, error 区间、字符或选项错误 +func (f *fontShaper) ShapeTextRun(value []rune, start, end int, size float64, options TextShapeOptions) ([]ShapedGlyph, error) { + if options.Bidi { + return nil, fmt.Errorf("bidirectional segmentation is not valid for an individual shaping run") + } + return f.shapeTextRun(value, start, end, size, options) +} + +// shapeTextRun 共用完整行与局部区间的选项校验、字体初始化及字形塑形 +// 入参: runes 原文符文, start 起始符文索引, end 结束符文索引, size 毫米字号, options 塑形选项 +// 返回: []ShapedGlyph 定位字形, error 字体、区间或选项错误 +func (f *fontShaper) shapeTextRun(runes []rune, start, end int, size float64, options TextShapeOptions) ([]ShapedGlyph, error) { + if !rasterPositive(size) { + return nil, fmt.Errorf("invalid shaping size") + } + if start < 0 || end < start || end > len(runes) { + return nil, fmt.Errorf("invalid shaping text range") + } + for _, char := range runes[start:end] { + if !utf8.ValidRune(char) || unicode.IsControl(char) || char == '\u2028' || char == '\u2029' { return nil, fmt.Errorf("shaping requires a single text line") } } @@ -113,6 +140,9 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te features = append(features, feature) } } + if start == end { + return nil, nil + } f.shapeMu.Lock() defer f.shapeMu.Unlock() if f.shapeFont == nil { @@ -125,24 +155,32 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te f.shapeBuffer = harfbuzz.NewBuffer() } if options.Bidi { - return f.shapeBidiText([]rune(value), size, props, features) + return f.shapeBidiText(runes, size, props, features) } buffer := f.shapeBuffer buffer.Clear() + defer f.releaseShapeBuffer() buffer.Props = props - runes := []rune(value) - buffer.AddRunes(runes, 0, len(runes)) + buffer.AddRunes(runes, start, end-start) buffer.GuessSegmentProperties() buffer.Shape(f.shapeFont, features) unit := size / float64(f.metrics.UnitsPerEm()) result := make([]ShapedGlyph, len(buffer.Info)) var x, y float64 + previous, order := -1, -1 for i, info := range buffer.Info { if info.Glyph >= typefont.GID(f.metrics.NumGlyphs()) { return nil, fmt.Errorf("invalid shaped glyph index %d", info.Glyph) } + if info.Cluster < start || info.Cluster >= end { + return nil, fmt.Errorf("invalid shaped cluster index %d", info.Cluster) + } + if info.Cluster != previous { + previous = info.Cluster + order++ + } position := buffer.Pos[i] - result[i] = ShapedGlyph{Glyph: uint16(info.Glyph), Cluster: info.Cluster, + result[i] = ShapedGlyph{Glyph: uint16(info.Glyph), Cluster: info.Cluster, VisualOrder: order, X: (x + float64(position.XOffset)) * unit, Y: -(y + float64(position.YOffset)) * unit, Advance: float64(position.XAdvance) * unit} if !finite(result[i].X) || !finite(result[i].Y) || !finite(result[i].Advance) { @@ -155,6 +193,17 @@ func (f *fontShaper) ShapeTextWithOptions(value string, size float64, options Te return result, nil } +// releaseShapeBuffer 释放原文上下文和超限缓冲,普通文本继续复用已有容量 +func (f *fontShaper) releaseShapeBuffer() { + buffer := f.shapeBuffer + if cap(buffer.Info) > fontShapingBufferLimit || cap(buffer.Pos) > fontShapingBufferLimit { + f.shapeBuffer = harfbuzz.NewBuffer() + return + } + buffer.Clear() + buffer.AddRunes(nil, 0, 0) +} + // ResolveFace 返回指定字体 // 入参: char 字符 // 返回: *typefont.Face 字体 @@ -211,12 +260,19 @@ func (f *fontShaper) shapeBidiText(runes []rune, size float64, props harfbuzz.Se return nil, fmt.Errorf("invalid shaped glyph index %d", glyph.GlyphID) } cluster := glyph.TextIndex() + if cluster < 0 || cluster >= len(runes) { + return nil, fmt.Errorf("invalid shaped cluster index %d", cluster) + } order, exists := visual[cluster] if !exists { order = len(visual) visual[cluster] = order } - result = append(result, ShapedGlyph{Glyph: uint16(glyph.GlyphID), Cluster: cluster, VisualOrder: order, X: (x + float64(glyph.XOffset)) * unit, Y: -float64(glyph.YOffset) * unit, Advance: float64(glyph.Advance) * unit}) + shaped := ShapedGlyph{Glyph: uint16(glyph.GlyphID), Cluster: cluster, VisualOrder: order, X: (x + float64(glyph.XOffset)) * unit, Y: -float64(glyph.YOffset) * unit, Advance: float64(glyph.Advance) * unit} + if !finite(shaped.X) || !finite(shaped.Y) || !finite(shaped.Advance) { + return nil, fmt.Errorf("shaped position exceeds finite range") + } + result = append(result, shaped) x += float64(glyph.Advance) } } diff --git a/ofdgo_font_type2.go b/ofdgo_font_type2.go index 9ea19fc..0912831 100644 --- a/ofdgo_font_type2.go +++ b/ofdgo_font_type2.go @@ -30,6 +30,7 @@ type type2Font struct { names map[string]int nominal, def float64 seed uint64 + cid bool } // type2State 按Type2规范保存跨子程序共享的执行状态 @@ -489,6 +490,9 @@ func (s *type2State) run(data []byte, depth int) (bool, error) { // 入参: code 标准字符码, x 横向偏移, y 纵向偏移 // 返回: error 组件或指令错误 func (s *type2State) appendComponent(code, x, y float64) error { + if s.font.cid { + return fmt.Errorf("CID CFF cannot use StandardEncoding components") + } if code < 0 || code > 255 || code != math.Trunc(code) || type1StandardNames[int(code)] == "" { return fmt.Errorf("invalid Type2 component code") } diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 2a78223..7975097 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -34,7 +34,7 @@ var ErrPDFPassword = pdfgo.ErrPassword // RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置 // RasterDPI指定局部合成与三维绘制精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成 // OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知 -// ResolveFile读取PDF引用的外部媒体、附件和页面,默认不访问网络或本地路径 +// ResolveFile读取PDF引用的外部媒体、附件、页面及三维纹理,默认不访问网络或本地路径 // ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护 // Halftones提供只读设备命名网屏,所属阅读器由调用方维护,连续色调输出不网屏化 // Compression配置转换输出,有损模式按图片实际尺寸降采样,原始及无损模式保持完整采样 @@ -156,7 +156,12 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI return nil, PDFImportReport{}, err } } - readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8} + editor := NewEditor() + editor.SetRenderBackends(renderer.Backends()) + editor.fontDirs, editor.fontFS = renderer.FontSources() + editor.fontSourcesCache = renderer.fontSourcesCache + importer := pdfImporter{ctx: ctx, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} + readOptions := pdfgo.ReaderOptions{Password: options.Password, PasswordUTF8: options.PasswordUTF8, ShapeText: importer.shapePDFText} var diagnostics []pdfgo.Diagnostic var readWarning func(pdfgo.Diagnostic) if !options.Strict { @@ -168,11 +173,7 @@ func ImportPDF(ctx context.Context, source io.ReaderAt, size int64, options PDFI return nil, PDFImportReport{}, err } defer reader.Close() - editor := NewEditor() - editor.SetRenderBackends(renderer.Backends()) - editor.fontDirs, editor.fontFS = renderer.FontSources() - editor.fontSourcesCache = renderer.fontSourcesCache - importer := pdfImporter{ctx: ctx, reader: reader, editor: editor, renderer: renderer, rasterDPI: renderer.DPI, fontIDs: map[*pdfgo.Font]string{}, fonts: map[*pdfgo.Font]*pdfImportedFont{}, pages: map[pdfgo.Reference]*pdfgo.Page{}, pageIDs: map[pdfgo.Reference]string{}} + importer.reader = reader importer.report.Warnings = diagnostics importer.pageIndexes = make(map[pdfgo.Reference]int) importer.resolveFile = options.ResolveFile diff --git a/ofdgo_pdf_3d.go b/ofdgo_pdf_3d.go index 6ebae92..c9920c0 100644 --- a/ofdgo_pdf_3d.go +++ b/ofdgo_pdf_3d.go @@ -30,10 +30,16 @@ const pdfModelAttachmentLimit = 1024 // PDFThreeDRenderOptions 控制PDF三维视口的静态绘制,不改变注解激活状态 // 缓存与实例上限为0时采用U3DRenderOptions的默认值 +// ResolveTexture按原始URL提供外部纹理,空值不访问外部资源 +// Nodes按名称替换PDF视图中的节点状态,可指定动作轨道与采样时间,不改变原始注解 +// Time以秒指定模型动画的采样时刻,零值为初始时刻 type PDFThreeDRenderOptions struct { Width, Height int MaxMemoryBytes int64 MaxInstances int + ResolveTexture U3DTextureResolver + Nodes []U3DNodeState + Time float64 } // threeDAnnotation 按非交互呈现规则处理激活状态,保留原始模型与附件动作 @@ -147,7 +153,13 @@ func (p *pdfImporter) threeDPainter(ctx context.Context, page *pdfgo.Page, sourc if !finite(w) || !finite(h) || w <= 0 || h <= 0 || w > 1<<30 || h > 1<<30 { return nil, fmt.Errorf("invalid PDF 3D raster dimensions") } - img, err := RenderPDFThreeD(ctx, model, source, PDFThreeDRenderOptions{Width: int(w), Height: int(h)}) + options := PDFThreeDRenderOptions{Width: int(w), Height: int(h)} + if p.resolveFile != nil { + options.ResolveTexture = func(ctx context.Context, address string) ([]byte, error) { + return p.resolveFile(ctx, pdfgo.FileSpecification{Name: address, FileSystem: "URL"}) + } + } + img, err := RenderPDFThreeD(ctx, model, source, options) if err != nil { return nil, err } @@ -166,7 +178,7 @@ func (p *pdfImporter) threeDPainter(ctx context.Context, page *pdfgo.Page, sourc }, nil } -// RenderPDFThreeD 按PDF相机、投影、背景与灯光绘制3DB区域,不执行脚本或动画 +// RenderPDFThreeD 按PDF相机、投影、背景与灯光绘制指定时刻的3DB区域,不执行脚本 // 返回图像不含3DB以外的注解背景,未支持的视图效果返回明确错误 // 每次从传入场景建立独立节点状态,不继承此前绘制的运行时状态 // 入参: ctx 取消上下文, model 解码后的U3D模型, source PDF三维参数, options 输出与资源限制 @@ -200,7 +212,7 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD, if source.Projection != nil { projection = *source.Projection } - render := U3DRenderOptions{Width: options.Width, Height: options.Height, MaxMemoryBytes: options.MaxMemoryBytes, MaxInstances: options.MaxInstances, Background: source.Background.RGB} + render := U3DRenderOptions{Width: options.Width, Height: options.Height, MaxMemoryBytes: options.MaxMemoryBytes, MaxInstances: options.MaxInstances, Background: source.Background.RGB, ResolveTexture: options.ResolveTexture, Time: options.Time} if err := pdfThreeDRenderMode(source, &render); err != nil { return nil, err } @@ -210,7 +222,7 @@ func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD, if err := pdfThreeDSections(ctx, presentation.CrossSections, &render); err != nil { return nil, err } - r, err := newU3DRender(ctx, model, render) + r, err := newU3DRender(ctx, model, render, options.Nodes) if err != nil { return nil, err } diff --git a/ofdgo_pdf_font.go b/ofdgo_pdf_font.go index 21e0352..5cc95e9 100644 --- a/ofdgo_pdf_font.go +++ b/ofdgo_pdf_font.go @@ -16,11 +16,10 @@ package ofdgo import ( "bytes" + "context" "crypto/sha256" "encoding/binary" "fmt" - "maps" - "slices" "unicode/utf16" "unicode/utf8" @@ -42,6 +41,46 @@ type pdfFontRepair struct { locations bool } +// shapePDFText 使用导入字体整形注释文字,将毫米坐标约定转换为PDF文字空间方向 +// 入参: ctx 取消上下文, source 源字体, text 单行原文, rightToLeft 段落方向 +// 返回: []pdfgo.ShapedGlyph 千分之一字号的定位字形, error 字体、整形或取消错误 +func (p *pdfImporter) shapePDFText(ctx context.Context, source *pdfgo.Font, text string, rightToLeft bool) ([]pdfgo.ShapedGlyph, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if len(source.Program) == 0 { + return nil, &pdfgo.UnsupportedError{Feature: "annotation shaping without embedded font"} + } + font, err := p.importedFont(source) + if err != nil { + return nil, err + } + shaper, ok := font.metrics.(FontShaperOptions) + if !ok { + return nil, &pdfgo.UnsupportedError{Feature: "annotation text shaping backend"} + } + options := TextShapeOptions{Bidi: true} + if rightToLeft { + options.Direction = "rtl" + } + glyphs, err := shaper.ShapeTextWithOptions(text, 1000, options) + if err != nil { + return nil, err + } + result := make([]pdfgo.ShapedGlyph, len(glyphs)) + units := float64(font.metrics.UnitsPerEm()) + if units <= 0 { + return nil, fmt.Errorf("invalid annotation font units") + } + for i, glyph := range glyphs { + if err := ctx.Err(); err != nil { + return nil, err + } + result[i] = pdfgo.ShapedGlyph{ID: glyph.Glyph, Cluster: glyph.Cluster, VisualOrder: glyph.VisualOrder, X: glyph.X, Y: -glyph.Y, Advance: glyph.Advance, Width: float64(font.metrics.GlyphAdvance(glyph.Glyph)) * 1000 / units} + } + return result, ctx.Err() +} + // embeddedTextFont 匹配同名内嵌字体,仅复用唯一且覆盖全部字符的字形程序 // 入参: source 外部字体, glyphs 当前文字字形 // 返回: *pdfImportedFont 可复用字体,无可靠匹配时为空 @@ -292,33 +331,13 @@ func pdfFontProgram(source *pdfgo.Font, type1 *type1Program) ([]byte, pdfFontRep } } if len(tables["cmap"]) == 0 || source.Subtype == "Type0" && invalidCmap || (source.Subtype == "Type0" || source.Subtype == "TrueType") && pdfCmapNeedsRebuild(tables["cmap"]) { - mapping := map[rune]uint16{} - var codes []string - if source.Subtype == "TrueType" { - codes = make([]string, 256) - for code := range codes { - codes[code] = string([]byte{byte(code)}) - } - } else { - codes = slices.Sorted(maps.Keys(source.Unicode)) + mapping, err := source.GlyphMap() + if err != nil { + return nil, repair, err } - for _, code := range codes { - glyphs, err := source.Decode([]byte(code)) - if err != nil { - return nil, repair, err - } - if len(glyphs) != 1 || !glyphs[0].HasID { - return nil, repair, &pdfgo.UnsupportedError{Feature: "font without explicit glyph mapping"} - } - glyph := glyphs[0] - if glyph.ID >= count { - return nil, repair, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph.ID, count) - } - if utf8.RuneCountInString(glyph.Text) == 1 { - char, _ := utf8.DecodeRuneInString(glyph.Text) - if _, exists := mapping[char]; !exists { - mapping[char] = glyph.ID - } + for _, glyph := range mapping { + if glyph >= count { + return nil, repair, fmt.Errorf("PDF font glyph %d exceeds glyph count %d", glyph, count) } } addPackedGlyphMapping(mapping, count) diff --git a/ofdgo_pdf_text_replacement.go b/ofdgo_pdf_text_replacement.go index f6ef2b7..58f3387 100644 --- a/ofdgo_pdf_text_replacement.go +++ b/ofdgo_pdf_text_replacement.go @@ -25,11 +25,13 @@ import ( // pdfTextReplacement 记录一个完整替换区段的文字数量与转换状态 type pdfTextReplacement struct { - text *pdfgo.ReplacementText - count int - offset int64 - blocked bool - applied bool + text *pdfgo.ReplacementText + count int + matched int + offset int64 + blocked bool + applied bool + identity bool } // pdfTextReplacements 按绘制顺序收集替换区段,避免拆分或重复写入原文 @@ -104,21 +106,26 @@ func (r *pdfTextReplacements) markedContent(mark pdfgo.MarkedContentMark) error return nil } -// apply 将完整且样式一致的区段合并为标准OFD文字对象 +// apply 合并可等价表示的替换区段,原文相同但样式不同的区段保留原图元 // 入参: nodes 原始图元 // 返回: []pdfCompositeNode 转换图元, error 取消或语义转换错误 func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNode, error) { if len(r.order) == 0 { return nodes, nil } - r.count(nodes, false) + if err := r.count(nodes, false); err != nil { + return nil, err + } + for _, span := range r.order { + span.identity = !span.blocked && span.count > 0 && span.matched == len(span.text.Text) + } var err error nodes, err = r.merge(nodes) if err != nil { return nil, err } for _, span := range r.order { - if span.applied || !span.blocked && span.count == 0 && span.text.Text == "" { + if span.applied || span.identity || !span.blocked && span.count == 0 && span.text.Text == "" { continue } if r.importer.warning == nil { @@ -130,14 +137,33 @@ func (r *pdfTextReplacements) apply(nodes []pdfCompositeNode) ([]pdfCompositeNod return nodes, nil } -// count 统计完整区段中的文字图元,排除需要组级合成的内容 +// count 统计区段文字并逐字核对原文,排除需要组级合成的内容 // 入参: nodes 图元, blocked 上层组是否阻止直接转换 -func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) { +// 返回: error 取消错误 +func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) error { for _, node := range nodes { + if err := r.importer.ctx.Err(); err != nil { + return err + } if node.text != nil { if span := r.span(node.text.Replacement); span != nil { span.count++ span.blocked = span.blocked || blocked + for i, glyph := range node.text.Glyphs { + if span.matched < 0 { + break + } + if i%256 == 0 { + if err := r.importer.ctx.Err(); err != nil { + return err + } + } + if glyph.Text == "" || !strings.HasPrefix(span.text.Text[span.matched:], glyph.Text) { + span.matched = -1 + break + } + span.matched += len(glyph.Text) + } } } childBlocked := blocked @@ -145,8 +171,11 @@ func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) { opaque, err := node.opaque(group.ColorSpace) childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent() } - r.count(node.children, childBlocked) + if err := r.count(node.children, childBlocked); err != nil { + return err + } } + return nil } // merge 合并同层连续图元,保留原始绘制顺序与字形坐标 @@ -171,7 +200,7 @@ func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNod first = node.children[0].text } if first != nil { - if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" { + if span := r.span(first.Replacement); span != nil && !span.applied && !span.blocked && span.text.Text != "" && !(span.identity && span.count == 1) { var marks []pdfCompositeNode for end = i; end < len(nodes); end++ { current := nodes[end] @@ -221,7 +250,7 @@ func (r *pdfTextReplacements) merge(nodes []pdfCompositeNode) ([]pdfCompositeNod return result, nil } -// clear 清除未转换的区段引用,防止局部绘制重复使用整段原文 +// clear 清除无需替换及未转换的区段引用,防止局部绘制重复使用整段原文 // 入参: nodes 图元 func (r *pdfTextReplacements) clear(nodes []pdfCompositeNode) { for _, node := range nodes { diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 9fc576e..af618ed 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -17,6 +17,7 @@ package ofdgo import ( "archive/zip" "bytes" + "context" "encoding/xml" "fmt" "io" @@ -546,6 +547,31 @@ func (r *Reader) readFileView(name string) ([]byte, error) { return data, err } +// readFileViewContext 读取包内文件并响应取消,已加载数据直接借用 +// 入参: ctx 取消上下文, name 文件名 +// 返回: []byte 只读文件内容, error 读取或取消错误 +func (r *Reader) readFileViewContext(ctx context.Context, name string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if data, ok := r.files[cleanPackagePath(name)]; ok { + return data, nil + } + input, err := r.openFile(name) + if err != nil { + return nil, err + } + defer input.Close() + data, err := io.ReadAll(imageInput{ReadCloser: input, context: ctx}) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return data, nil +} + // readFileData 统一解析包内路径并区分缓存借用与独立读取 // 入参: name 文件名 // 返回: []byte 文件内容, bool 是否借用缓存, error 错误信息 diff --git a/ofdgo_u3d.go b/ofdgo_u3d.go index d1a3a12..b01ffdc 100644 --- a/ofdgo_u3d.go +++ b/ofdgo_u3d.go @@ -31,7 +31,7 @@ type U3DOptions struct { MaxDecodedBytes int64 } -// U3DModel 保存独立于PDF的静态U3D场景,不执行动作或读取外部文件 +// U3DModel 保存独立于PDF的U3D场景及动作数据,不执行动作或读取外部文件 type U3DModel struct { Major, Minor int16 DefinedUnits bool @@ -42,6 +42,8 @@ type U3DModel struct { Shaders []U3DShader Lights []U3DLight Views []U3DViewResource + Textures []U3DTexture + Motions []U3DMotion Metadata []U3DMetadata } @@ -61,6 +63,8 @@ type U3DNode struct { LineShaders [][]string PointShaders [][]string View *U3DView + BoneWeights []U3DBoneWeights + Animations []U3DAnimation } // U3DParent 保存父节点名称及局部到父节点的变换,空名称引用默认节点 @@ -77,12 +81,13 @@ type U3DMaterial struct { Reflectivity, Opacity float32 } -// U3DShader 保存无纹理着色器参数,不提前应用混合、透明测试或光照 +// U3DShader 保存材质与八个纹理通道,Textures中的空项表示未启用 type U3DShader struct { Name, Material string Attributes, AlphaFunction, Blend uint32 RenderPass uint32 AlphaReference float32 + Textures [8]*U3DTextureLayer } // U3DLight 保存环境、方向、点或聚光资源,Kind依次为0至3 @@ -110,10 +115,12 @@ type u3dDecoder struct { meshOrder []string nodes map[string]int palettes map[uint32]map[string]bool + textures map[string]int } -// DecodeU3D 按ECMA-363解码静态基础网格、线集、点集、组、材质、着色器和光源 -// 暂不支持渐进网格、纹理和动画,遇到未支持内容返回错误而非部分场景 +// DecodeU3D 按ECMA-363解码静态几何、骨架、场景节点及材质、纹理图像等资源 +// 保留动作轨道和动画修饰器,不在解码时求值;绘制能力由RenderU3D另行校验 +// 渐进网格尚未支持 // 点集球面预测暂限不超过两个非对跖方向,不推测多方向合并次序 // 入参: ctx 取消上下文, data U3D数据, options 解码限制 // 返回: *U3DModel 独立场景数据, error 格式、能力、预算或取消错误 @@ -177,7 +184,7 @@ func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel, if err != nil { return nil, fmt.Errorf("U3D block at %d: %w", offset, err) } - continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c || block.kind == 0xffffff3e || block.kind == 0xffffff3f + continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c || block.kind == 0xffffff3e || block.kind == 0xffffff3f || block.kind == 0xffffff5c if uint64(offset) < declarationEnd && (uint64(end) > declarationEnd || continuation) || uint64(offset) >= declarationEnd && !continuation { return nil, fmt.Errorf("invalid U3D declaration boundary at %d", offset) } @@ -209,9 +216,15 @@ func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel, } d.model.Meshes = append(d.model.Meshes, mesh.mesh) } + if err := d.checkTextures(); err != nil { + return nil, err + } if err := d.checkParents(); err != nil { return nil, err } + if err := d.checkBoneWeights(); err != nil { + return nil, err + } if err := ctx.Err(); err != nil { return nil, err } @@ -307,6 +320,12 @@ func (d *u3dDecoder) chain(block u3dBlock) error { } r := u3dValues{data: block.data} name, kind, flags := r.text(), r.u32(), r.u32() + if r.err != nil { + return r.err + } + if name == "" { + return fmt.Errorf("cannot redefine U3D default resource") + } if kind > 2 || flags & ^uint32(3) != 0 { return fmt.Errorf("invalid U3D modifier chain") } @@ -367,6 +386,12 @@ func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32 } r := u3dValues{data: block.data} name := r.text() + if r.err != nil { + return "", r.err + } + if name == "" { + return "", fmt.Errorf("cannot redefine U3D default resource") + } var err error switch block.kind { case 0xffffff21, 0xffffff22, 0xffffff23, 0xffffff24: @@ -394,16 +419,38 @@ func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32 return name, fmt.Errorf("invalid U3D primitive continuation location") } err = d.primitiveContinuation(&r, name, block.kind == 0xffffff3e) + case 0xffffff43, 0xffffff44: + if (chain != 0 && chain != 1) || index == 0 || owner != name || r.u32() != index { + return name, fmt.Errorf("invalid U3D animation or bone weight modifier chain") + } + if _, exists := d.nodes[name]; chain == 0 && !exists || chain == 1 && d.meshes[name] == nil { + return name, fmt.Errorf("missing U3D animation or bone weight modifier target") + } + if block.kind == 0xffffff43 { + err = d.animation(&r, name, chain, index) + } else { + err = d.boneWeights(&r, name, chain, index) + } case 0xffffff45: if chain != 0 || index == 0 || owner != name || r.u32() != index { return name, fmt.Errorf("invalid U3D shading modifier chain") } err = d.shading(&r, name) - case 0xffffff51, 0xffffff52, 0xffffff53, 0xffffff54: + case 0xffffff51, 0xffffff52, 0xffffff53, 0xffffff54, 0xffffff56: if chain >= 0 { return name, fmt.Errorf("invalid U3D palette resource location") } err = d.resource(&r, block.kind, name) + case 0xffffff55: + if chain >= 0 && (chain != 2 || index != 0) { + return name, fmt.Errorf("invalid U3D texture chain") + } + err = d.textureDeclaration(&r, name) + case 0xffffff5c: + if chain >= 0 { + return name, fmt.Errorf("invalid U3D texture continuation location") + } + err = d.textureContinuation(&r, name) default: return name, fmt.Errorf("unsupported U3D block %#x", block.kind) } @@ -513,7 +560,7 @@ func (d *u3dDecoder) shading(r *u3dValues, name string) error { return r.err } -// resource 读取独立材质、着色器、光源和视图资源 +// resource 读取独立材质、着色器、光源、视图和动作资源 // 入参: r 字段读取器, kind 资源类型, name 资源名 // 返回: error 结构或能力错误 func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error { @@ -527,6 +574,8 @@ func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error { } palette[name] = true switch kind { + case 0xffffff56: + return d.motion(r, name) case 0xffffff52: return d.viewResource(r, name) case 0xffffff54: @@ -548,8 +597,28 @@ func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error { if shader.Attributes & ^uint32(7) != 0 || shader.AlphaFunction < 0x610 || shader.AlphaFunction > 0x617 || shader.Blend < 0x604 || shader.Blend > 0x607 || channels & ^uint32(255) != 0 || alpha & ^channels != 0 { return fmt.Errorf("invalid U3D shader parameters") } - if channels != 0 { - return fmt.Errorf("unsupported U3D textured shader") + for channel := range shader.Textures { + if channels&(1< 3 || layer.BlendSource > 1 || layer.Mode > 4 || layer.Repeat > 3 { + return fmt.Errorf("invalid U3D texture layer") + } + shader.Textures[channel] = layer } d.model.Shaders = append(d.model.Shaders, shader) case 0xffffff51: diff --git a/ofdgo_u3d_animation.go b/ofdgo_u3d_animation.go new file mode 100644 index 0000000..c6bd339 --- /dev/null +++ b/ofdgo_u3d_animation.go @@ -0,0 +1,176 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "context" + "fmt" + "io" +) + +// U3DAnimation 保存ECMA-363第9.7.3节的动画修饰器,Index为链内位置 +// Attributes的1、2、4、8分别表示启动、锁定根骨骼、单轨播放和动作间平滑过渡 +// TimeScale为整体时间倍率,BlendTime以毫秒计;Motions为空时使用无轨道的默认动作 +type U3DAnimation struct { + Index, Attributes uint32 + TimeScale, BlendTime float32 + Motions []U3DAnimationMotion +} + +// U3DAnimationMotion 保存动画中的动作引用,空名称引用默认动作 +// Attributes的1、2分别表示循环和同步结束,TimeOffset以毫秒计,TimeScale为动作时间倍率 +type U3DAnimationMotion struct { + Name string + Attributes uint32 + TimeOffset, TimeScale float32 +} + +// animation 读取动画参数及有序动作引用,不在解码时调度或求值 +// 入参: r 字段读取器, name 所属链名, chain 链类型, index 链内位置 +// 返回: error 结构、参数、预算或取消错误 +func (d *u3dDecoder) animation(r *u3dValues, name string, chain int, index uint32) error { + animation := U3DAnimation{Index: index, Attributes: r.u32(), TimeScale: r.f32()} + count := r.u32() + if r.err != nil { + return r.err + } + if len(r.data)-r.pos < 4 || uint64(count) > uint64(len(r.data)-r.pos-4)/14 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 32); err != nil { + return err + } + if count != 0 { + animation.Motions = make([]U3DAnimationMotion, int(count)) + } + for i := range animation.Motions { + if i%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + motion := &animation.Motions[i] + motion.Name, motion.Attributes = r.text(), r.u32() + motion.TimeOffset, motion.TimeScale = r.f32(), r.f32() + if r.err != nil { + return r.err + } + } + animation.BlendTime = r.f32() + if err := r.done(); err != nil { + return err + } + if err := animation.validate(d.ctx); err != nil { + return err + } + if chain == 1 { + mesh := &d.meshes[name].mesh + mesh.Animations = append(mesh.Animations, animation) + } else { + node := &d.model.Nodes[d.nodes[name]] + node.Animations = append(node.Animations, animation) + } + return nil +} + +// validate 校验动画属性及有限数值,保留零倍率与反向播放倍率 +// 入参: ctx 取消上下文 +// 返回: error 参数或取消错误 +func (animation U3DAnimation) validate(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if animation.Index == 0 || animation.Attributes & ^uint32(15) != 0 || !finite(float64(animation.TimeScale)) || !finite(float64(animation.BlendTime)) || animation.BlendTime < 0 { + return fmt.Errorf("invalid U3D animation parameters") + } + for i, motion := range animation.Motions { + if i%256 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + if motion.Attributes & ^uint32(3) != 0 || !finite(float64(motion.TimeOffset)) || !finite(float64(motion.TimeScale)) { + return fmt.Errorf("invalid U3D animation motion parameters") + } + } + return nil +} + +// checkAnimations 校验全部动作引用,仅对接入场景的修饰器检查求值能力 +// 返回: error 引用、能力或取消错误 +func (r *u3dRender) checkAnimations() error { + check := func(animations []U3DAnimation, evaluate bool) error { + for _, animation := range animations { + if err := animation.validate(r.ctx); err != nil { + return err + } + for i, motion := range animation.Motions { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if motion.Name == "" { + continue + } + index, exists := r.motionIndex[motion.Name] + if !exists { + return fmt.Errorf("missing U3D animation motion %q", motion.Name) + } + if evaluate && len(animation.Motions) > 1 && len(r.model.Motions[index].Tracks) != 0 { + return fmt.Errorf("unsupported U3D animation evaluation for motion %q", motion.Name) + } + } + } + return nil + } + var active []bool + if r.connected != nil { + if err := r.reserve(uint64(len(r.model.Meshes)), 1); err != nil { + return err + } + defer func() { r.remaining += uint64(len(active)) }() + active = make([]bool, len(r.model.Meshes)) + for i, node := range r.model.Nodes { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if r.connected[i] && node.Kind == "Model" { + if mesh, exists := r.meshes[node.Resource]; exists { + active[mesh] = true + } + } + } + } + for i := range r.model.Meshes { + if err := r.ctx.Err(); err != nil { + return err + } + if err := check(r.model.Meshes[i].Animations, active == nil || active[i]); err != nil { + return err + } + } + for i := range r.model.Nodes { + if err := r.ctx.Err(); err != nil { + return err + } + if err := check(r.model.Nodes[i].Animations, r.connected == nil || r.connected[i]); err != nil { + return err + } + } + return r.ctx.Err() +} diff --git a/ofdgo_u3d_bits.go b/ofdgo_u3d_bits.go index dd52044..a1657ad 100644 --- a/ofdgo_u3d_bits.go +++ b/ofdgo_u3d_bits.go @@ -46,6 +46,27 @@ type u3dHistogram struct { total uint32 } +// finish 校验压缩块的冲刷字段及结束位置 +// 返回: error 截断、冲刷或多余数据错误 +func (r *u3dBits) finish() error { + if r.err != nil { + return r.err + } + if r.pos > uint64(len(r.data))*8 { + return io.ErrUnexpectedEOF + } + if r.u32() != 0 { + return fmt.Errorf("invalid U3D compression flush") + } + if r.err != nil { + return r.err + } + if (r.pos-16+7)/8 != uint64(len(r.data)) { + return fmt.Errorf("unexpected U3D compressed block data") + } + return nil +} + // take 读取定长字段,失败后不继续推进位置 // 入参: size 字节数 // 返回: []byte 字段数据,失败时为空 diff --git a/ofdgo_u3d_light.go b/ofdgo_u3d_light.go index e4c429d..44a58a9 100644 --- a/ofdgo_u3d_light.go +++ b/ofdgo_u3d_light.go @@ -63,10 +63,13 @@ func (r *u3dRender) collectLights() error { continue } j, ok := r.lightIndex[node.Resource] - if !ok { + if !ok && node.Resource != "" { continue } - light := r.model.Lights[j] + light := U3DLight{Attributes: 1, Color: [3]float32{.75, .75, .75}} + if ok { + light = r.model.Lights[j] + } if light.Attributes&1 == 0 { continue } @@ -152,11 +155,21 @@ func (r *u3dRender) overrideLighting(lighting *U3DLighting) error { return nil } -// style 解析着色器与材质,未启用的材质项使用固定默认值 +// shaderPass 返回着色器的绘制轮次位图,默认着色器仅参与第一轮 +// 入参: name 着色器名 +// 返回: uint32 绘制轮次位图 +func (r *u3dRender) shaderPass(name string) uint32 { + if index, exists := r.shaders[name]; exists { + return r.model.Shaders[index].RenderPass + } + return 1 +} + +// style 解析着色器与材质,缺失资源采用ECMA-363第8.4节的默认资源 // 入参: name 着色器名 // 返回: u3dRenderStyle 有效绘制参数, error 参数错误 func (r *u3dRender) style(name string) (u3dRenderStyle, error) { - style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, AlphaFunction: 0x617, RenderPass: math.MaxUint32}, ambient: [3]float64{.2, .2, .2}, diffuse: [3]float64{.8, .8, .8}, opacity: 1} + style := u3dRenderStyle{shader: U3DShader{Attributes: 1, Blend: 0x606, AlphaFunction: 0x617, RenderPass: 1}, ambient: [3]float64{.75, .75, .75}, opacity: 1} if index, ok := r.shaders[name]; ok { style.shader = r.model.Shaders[index] } @@ -166,6 +179,7 @@ func (r *u3dRender) style(name string) (u3dRenderStyle, error) { } if index, ok := r.materials[shader.Material]; ok { material := r.model.Materials[index] + style.ambient, style.diffuse = [3]float64{.2, .2, .2}, [3]float64{.8, .8, .8} if material.Attributes > 63 { return style, fmt.Errorf("invalid U3D render material") } diff --git a/ofdgo_u3d_line.go b/ofdgo_u3d_line.go index c090477..a572635 100644 --- a/ofdgo_u3d_line.go +++ b/ofdgo_u3d_line.go @@ -68,15 +68,13 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3 if err := r.ctx.Err(); err != nil { return err } - style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}} + style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: r.shaderPass(name)}} var err error if shaded { style, err = r.style(name) if err != nil { return err } - } else if index, ok := r.shaders[name]; ok { - style.shader.RenderPass = r.model.Shaders[index].RenderPass } if style.shader.RenderPass&(1<= 0 || r.opacity >= 0 { style.shader.Blend = 0x606 } + textures, err := r.shaderTextures(style.shader) + if err != nil { + return err + } for i, corner := range corners { + var normal u3dVector + if shaded && !mesh.ExcludeNormals { + normal = normals[corner.Normal] + } + if err := textures.coordinates(&vertices[i], mesh, shading, corner, normal); err != nil { + return err + } vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1} if !shaded { continue } - var normal u3dVector - if !mesh.ExcludeNormals { - normal = normals[corner.Normal] - } var diffuse, specular *[4]float32 if shading.Attributes&1 != 0 { diffuse = &mesh.Diffuse[corner.Diffuse] @@ -106,8 +111,9 @@ func (r *u3dRender) drawPrimitives(instance u3dInstance, node *U3DNode, mesh *U3 } if instance.opacity >= 0 { vertices[i].color[3] = instance.opacity + vertices[i].opacityOverride = true } - if r.opacity >= 0 { + if r.opacity >= 0 && textures == nil { vertices[i].color[3] *= r.opacity } } @@ -226,7 +232,7 @@ func u3dClipFraction(a, b float64) float64 { return a / (a + b) } -// u3dInterpolate 在相机空间插值坐标与未预乘颜色 +// u3dInterpolate 在相机空间插值位置、颜色与纹理输入 // 入参: a 起点, b 终点, t 比例 // 返回: u3dVertex 插值顶点 func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex { @@ -237,6 +243,18 @@ func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex { for i := range result.color { result.color[i] = a.color[i]*(1-t) + b.color[i]*t } + result.textureMask = a.textureMask | b.textureMask + result.opacityOverride = a.opacityOverride + if result.textureMask != 0 { + for layer := range result.texture { + if result.textureMask&(1< uint64(len(b.data))*8 { - return io.ErrUnexpectedEOF - } - if b.u32() != 0 { - return fmt.Errorf("invalid U3D primitive compression flush") - } - if b.err != nil { - return b.err - } - if (b.pos-16+7)/8 != uint64(len(b.data)) { - return fmt.Errorf("unexpected U3D primitive compressed data") + if err := b.finish(); err != nil { + return err } r.pos = len(r.data) } diff --git a/ofdgo_u3d_mapping.go b/ofdgo_u3d_mapping.go new file mode 100644 index 0000000..b2d4679 --- /dev/null +++ b/ofdgo_u3d_mapping.go @@ -0,0 +1,141 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" +) + +// u3dTextureInverse 计算包裹变换的逆矩阵,保留非仿射齐次分量 +// 入参: matrix 按列排列的包裹变换 +// 返回: [16]float64 逆矩阵, error 奇异矩阵或非有限结果 +func u3dTextureInverse(matrix [16]float32) ([16]float64, error) { + var rows [4][8]float64 + for row := range 4 { + for col := range 4 { + rows[row][col] = float64(matrix[col*4+row]) + if !finite(rows[row][col]) { + return [16]float64{}, fmt.Errorf("invalid U3D texture wrap transform") + } + } + rows[row][row+4] = 1 + } + for col := range 4 { + pivot := col + for row := col + 1; row < 4; row++ { + if math.Abs(rows[row][col]) > math.Abs(rows[pivot][col]) { + pivot = row + } + } + if rows[pivot][col] == 0 { + return [16]float64{}, fmt.Errorf("singular U3D texture wrap transform") + } + rows[col], rows[pivot] = rows[pivot], rows[col] + divisor := rows[col][col] + for i := range 8 { + rows[col][i] /= divisor + } + for row := range 4 { + if row == col { + continue + } + factor := rows[row][col] + for i := range 8 { + rows[row][i] -= factor * rows[col][i] + } + } + } + var result [16]float64 + for row := range 4 { + for col := range 4 { + value := rows[row][col+4] + if !finite(value) { + return [16]float64{}, fmt.Errorf("invalid U3D inverse texture wrap transform") + } + result[col*4+row] = value + } + } + return result, nil +} + +// generated 将插值后的位置或法线映射到纹理,再应用纹理变换 +// 经线未定义时取0,球心纬度取1/2,反射方向奇点取图像中心 +// 入参: channel 通道索引, input 模型位置或相机法线, position 相机空间位置 +// 返回: [3]float64 齐次纹理坐标, error 非有限值或无效投影 +func (s *u3dTextureSet) generated(channel int, input [3]float64, position u3dVector) ([3]float64, error) { + layer := s.layers[channel] + for _, value := range input { + if !finite(value) { + return [3]float64{}, fmt.Errorf("invalid U3D texture mapping input") + } + } + uv := [4]float64{0, 0, 0, 1} + if layer.Mode == 4 { + incident := u3dVector{0, 0, 1} + if s.perspective { + incident = position.unit() + } + normal := u3dVector(input).unit() + factor := 2 * normal.dot(incident) + reflected := incident.sub(u3dVector{factor * normal[0], factor * normal[1], factor * normal[2]}) + denominator := 2 * math.Hypot(math.Hypot(reflected[0], reflected[1]), 1-reflected[2]) + uv[0], uv[1] = .5, .5 + if denominator != 0 { + uv[0] += reflected[0] / denominator + uv[1] -= reflected[1] / denominator + } + } else { + var point [4]float64 + for row := range 4 { + point[row] = s.wrap[channel][12+row] + for col, value := range input { + point[row] += s.wrap[channel][col*4+row] * value + } + } + for i := range 3 { + point[i] /= point[3] + if !finite(point[i]) { + return [3]float64{}, fmt.Errorf("invalid U3D texture wrap projection") + } + } + switch layer.Mode { + case 1: + uv[0], uv[1] = point[0], point[1] + case 2, 3: + if point[0] != 0 || point[1] != 0 { + uv[0] = math.Atan2(point[1], point[0]) / (2 * math.Pi) + if uv[0] < 0 { + uv[0]++ + } + } + uv[1] = point[2] + if layer.Mode == 3 { + direction := (u3dVector{point[0], point[1], point[2]}).unit() + uv[1] = math.Acos(min(1, max(-1, direction[2]))) / math.Pi + } + } + } + var result [3]float64 + for component, row := range [3]int{0, 1, 3} { + for col, value := range uv { + result[component] += float64(layer.Transform[col*4+row]) * value + } + if !finite(result[component]) { + return [3]float64{}, fmt.Errorf("invalid U3D generated texture coordinate") + } + } + return result, nil +} diff --git a/ofdgo_u3d_mesh.go b/ofdgo_u3d_mesh.go index 13a8734..4b13b77 100644 --- a/ofdgo_u3d_mesh.go +++ b/ofdgo_u3d_mesh.go @@ -29,6 +29,9 @@ type U3DMesh struct { Faces []U3DFace Lines []U3DLine Points []U3DPoint + Bones []U3DBone + BoneWeights []U3DBoneWeights + Animations []U3DAnimation Quality [3]uint32 InverseQuantization [5]float32 NormalCrease, NormalUpdate, NormalTolerance float32 @@ -76,7 +79,7 @@ type u3dMeshDeclaration struct { // primitive 访问三角面、线段或点的着色编号和角点,不复制属性数组 // 入参: index 图元下标,依次为三角面、线段和点 -// 返回: uint32 着色编号, []U3DCorner 只读角点 +// 返回: uint32 着色编号, []U3DCorner 原数组中的角点切片 func (m *U3DMesh) primitive(index int) (uint32, []U3DCorner) { if index < len(m.Faces) { return m.Faces[index].Shading, m.Faces[index].Corners[:] @@ -116,16 +119,20 @@ func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error { decl.mesh.InverseQuantization[index] = r.f32() } decl.mesh.NormalCrease, decl.mesh.NormalUpdate, decl.mesh.NormalTolerance = r.f32(), r.f32(), r.f32() - bones := r.u32() if r.err != nil { return r.err } if minimum > maximum || maximum != decl.counts[1] { return fmt.Errorf("invalid U3D mesh resolution") } - if minimum != maximum || bones != 0 { - return fmt.Errorf("unsupported U3D progressive or skeletal mesh") + if minimum != maximum { + return fmt.Errorf("unsupported U3D progressive mesh") } + bones, err := d.skeleton(r) + if err != nil { + return err + } + decl.mesh.Bones = bones if minimum == 0 { for _, count := range decl.counts[:6] { if count != 0 { @@ -247,10 +254,16 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error { var histogram u3dHistogram var compressed *u3dBits if !d.plain && counts[0] != 0 { - if err := d.reserve(1, 1<<20); err != nil { - return err - } compressed = newU3DBits(r.data[r.pos:]) + var charged uint64 + compressed.reserve = func(count, size uint64) error { + if err := d.reserve(count, size); err != nil { + return err + } + charged += count * size + return nil + } + defer func() { d.remaining += charged }() } readIndex := func(count uint32) (uint32, error) { var value uint32 @@ -321,17 +334,8 @@ func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error { } } if compressed != nil { - if compressed.pos > uint64(len(compressed.data))*8 { - return io.ErrUnexpectedEOF - } - if compressed.u32() != 0 { - return fmt.Errorf("invalid U3D compression flush") - } - if compressed.err != nil { - return compressed.err - } - if (compressed.pos-16+7)/8 != uint64(len(compressed.data)) { - return fmt.Errorf("unexpected U3D compressed block data: decoded %d bits, stored %d bytes", compressed.pos-16, len(compressed.data)) + if err := compressed.finish(); err != nil { + return err } r.pos = len(r.data) } diff --git a/ofdgo_u3d_mode.go b/ofdgo_u3d_mode.go index 0c93361..e9179d3 100644 --- a/ofdgo_u3d_mode.go +++ b/ofdgo_u3d_mode.go @@ -40,12 +40,20 @@ const ( // u3dPrimitiveKey 区分共享点线的颜色及混合状态,避免透明边点重复叠加 type u3dPrimitiveKey struct { - a, b u3dVertex + a, b [7]float64 blend uint32 function uint32 reference float32 } +// u3dTexturedPrimitiveKey 额外区分纹理坐标及纹理层,不扩大普通边点缓存 +type u3dTexturedPrimitiveKey struct { + base u3dPrimitiveKey + coordinates [2][8][3]float64 + layers [8]*U3DTextureLayer + opacityOverride bool +} + // uniquePrimitive 在当前网格内去重着色边点,缓存按条目计入绘制预算 // 入参: a 起点或顶点, b 终点或相同顶点, shader 着色器 // 返回: bool 是否首次绘制, error 预算或取消错误 @@ -59,14 +67,29 @@ func (r *u3dRender) uniquePrimitive(a, b u3dVertex, shader U3DShader) (bool, err if left[i] != right[i] { if left[i] > right[i] { a, b = b, a + left, right = right, left } break } } - key := u3dPrimitiveKey{a: a, b: b, blend: shader.Blend} + key := u3dPrimitiveKey{a: left, b: right, blend: shader.Blend} if shader.Attributes&2 != 0 { key.function, key.reference = shader.AlphaFunction, shader.AlphaReference } + if shader.Textures != ([8]*U3DTextureLayer{}) { + textured := u3dTexturedPrimitiveKey{base: key, coordinates: [2][8][3]float64{a.texture, b.texture}, layers: shader.Textures, opacityOverride: a.opacityOverride} + if _, ok := r.texturedPrimitives[textured]; ok { + return false, nil + } + if err := r.reserve(1, 1024); err != nil { + return false, err + } + if r.texturedPrimitives == nil { + r.texturedPrimitives = make(map[u3dTexturedPrimitiveKey]struct{}) + } + r.texturedPrimitives[textured] = struct{}{} + return true, nil + } if _, ok := r.primitives[key]; ok { return false, nil } @@ -221,6 +244,13 @@ func (r *u3dRender) modePoint(vertex u3dVertex, shader U3DShader, pass U3DViewPa if vertex.point[2] > r.frame[pixel].depth { return nil } + textures, err := r.shaderTextures(shader) + if err != nil { + return err + } + if err := r.textureColor(textures, &vertex.color, &vertex.texture, vertex.point, vertex.opacityOverride); err != nil { + return err + } visible, err := u3dFragmentColor(&vertex.color, vertex.point, &shader, &pass) if err != nil || !visible { return err @@ -251,11 +281,10 @@ func (r *u3dRender) drawBounds(instance u3dInstance, node *U3DNode, mesh *U3DMes } } if uint64(shading) >= uint64(len(shaders)) { - visible = true + visible = visible || passIndex == 0 } else if !visible { for _, name := range shaders[shading] { - index, ok := r.shaders[name] - if !ok || r.model.Shaders[index].RenderPass&(1< 15 { + return fmt.Errorf("invalid U3D motion rotation signs") + } + var difference [4]float64 + var squared float64 + for component := 1; component < 4; component++ { + value := float64(r.value(5, false)) * float64(motion.RotationInverseQuantization) + if signs&(1< 1 { + return fmt.Errorf("invalid U3D motion rotation difference") + } + difference[0] = math.Sqrt(1 - squared) + if signs&1 != 0 { + difference[0] = -difference[0] + } + p := previous.Rotation + w, x, y, z := float64(p[0]), float64(p[1]), float64(p[2]), float64(p[3]) + a, b, c, e := difference[0], difference[1], difference[2], difference[3] + frame.Rotation = [4]float32{ + float32(w*a - x*b - y*c - z*e), + float32(w*b + x*a + y*e - z*c), + float32(w*c - x*e + y*a + z*b), + float32(w*e + x*c - y*b + z*a), + } + for _, value := range frame.Rotation { + if !finite(float64(value)) { + return fmt.Errorf("invalid U3D reconstructed rotation") + } + } + scale, err := r.motionDifference(previous.Scale[:], track.ScaleInverseQuantization, 6) + copy(frame.Scale[:], scale[:]) + return err +} + +// motionDifference 用共享幅值上下文恢复一至三个差分分量 +// 入参: previous 前一帧分量, scale 逆量化系数, context 符号上下文,下一项为幅值上下文 +// 返回: [3]float32 重建分量, error 符号、数值或读取错误 +func (r *u3dResourceReader) motionDifference(previous []float32, scale float32, context int) (values [3]float32, err error) { + signs := r.value(context, true) + if signs >= 1< 1 { + if err := r.reserve(uint64(len(group)), 48); err != nil { + return err + } + ordered = slices.Clone(group) + slices.SortFunc(ordered, func(a, b U3DAnimation) int { return cmp.Compare(a.Index, b.Index) }) + } + for index, animation := range ordered { + if err := r.ctx.Err(); err != nil { + return err + } + if index != 0 && animation.Index == ordered[index-1].Index { + return fmt.Errorf("duplicate U3D animation chain index") + } + if len(animation.Motions) == 0 || animation.Motions[0].Name == "" { + continue + } + motion := &r.model.Motions[r.motionIndex[animation.Motions[0].Name]] + if len(motion.Tracks) == 0 { + continue + } + used = true + if err := r.sampleAnimation(&state, node, mesh, &animation, motion, chain, time, &cache); err != nil { + return err + } + } + if len(group) > 1 { + r.remaining += uint64(len(group)) * 48 + } + } + if used { + if r.animated == nil { + if err := r.reserve(1, 1024); err != nil { + return err + } + r.animated = make(map[string]u3dAnimationState) + } + if err := r.reserve(1, 384); err != nil { + return err + } + r.animated[node.Name] = state + } + } + return r.ctx.Err() +} + +// animationTime 将毫秒偏移和两级时间倍率换算为动作时间,已绑定轨道共用循环区间 +// 入参: animation 动画修饰器, tracks 动作轨道, bindings 骨骼绑定,空值表示全部轨道, time 从初始时刻起算的秒数, cache 本次采样缓存 +// 返回: float64 动作采样时间, error 时间数值或取消错误 +func (r *u3dRender) animationTime(animation *U3DAnimation, tracks []U3DMotionTrack, bindings map[string]int, time float64, cache *u3dPlaybackCache) (float64, error) { + reference := animation.Motions[0] + if animation.Attributes&1 == 0 { + time = 0 + } + time = time*float64(animation.TimeScale)*float64(reference.TimeScale) - float64(reference.TimeOffset)/1000 + if !finite(time) { + return 0, fmt.Errorf("invalid U3D animation time") + } + minimum, maximum := math.Inf(1), math.Inf(-1) + for i, track := range tracks { + if err := r.ctx.Err(); err != nil { + return 0, err + } + if bindings != nil { + if index, exists := bindings[track.Name]; !exists || index != i { + continue + } + } + bounds, err := cache.limits(&tracks[i]) + if err != nil { + return 0, err + } + minimum, maximum = min(minimum, bounds[0]), max(maximum, bounds[1]) + } + if math.IsInf(minimum, 1) { + return 0, fmt.Errorf("empty U3D animation tracks") + } + if reference.Attributes&1 != 0 && maximum > minimum { + time = math.Mod(time-minimum, maximum-minimum) + if time < 0 { + time += maximum - minimum + } + time += minimum + } + return max(minimum, min(time, maximum)), r.ctx.Err() +} + +// sampleAnimation 求值单个动作的节点或骨骼姿态,锁定根骨骼时保留其初始变换 +// 入参: state 本节点累计状态, node 节点, mesh 模型几何, animation 修饰器, motion 动作, chain 链类别, time 时间, cache 本次采样缓存 +// 返回: error 轨道、修饰顺序、预算或取消错误 +func (r *u3dRender) sampleAnimation(state *u3dAnimationState, node *U3DNode, mesh *U3DMesh, animation *U3DAnimation, motion *U3DMotion, chain int, time float64, cache *u3dPlaybackCache) error { + if animation.Attributes&4 != 0 { + if chain == 0 { + return fmt.Errorf("unsupported U3D resource single-track animation") + } + time, err := r.animationTime(animation, motion.Tracks[:1], nil, time, cache) + if err != nil { + return err + } + frame, err := cache.sample(&motion.Tracks[0], time) + if err != nil { + return err + } + matrix, err := frame.Matrix() + if err != nil { + return err + } + if animation.Attributes&2 == 0 { + state.matrix = state.matrix.multiply(u3dMatrix(matrix)) + if !state.matrix.valid() { + return fmt.Errorf("invalid U3D animated node transform") + } + } + return nil + } + if node.Kind != "Model" || mesh == nil || len(mesh.Bones) == 0 { + return fmt.Errorf("missing U3D animation skeleton") + } + if state.weights != nil { + return fmt.Errorf("unsupported U3D successive skeletal animations") + } + if err := r.reserve(uint64(len(mesh.Bones)), 96); err != nil { + return err + } + defer func() { r.remaining += uint64(len(mesh.Bones)) * 96 }() + tracks := make(map[string]int, len(mesh.Bones)) + for i, bone := range mesh.Bones { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if _, exists := tracks[bone.Name]; exists { + return fmt.Errorf("duplicate U3D bone %q", bone.Name) + } + tracks[bone.Name] = -1 + } + bound := 0 + for i, track := range motion.Tracks { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if index, exists := tracks[track.Name]; exists { + if index >= 0 { + return fmt.Errorf("ambiguous U3D animation track %q", track.Name) + } + tracks[track.Name] = i + bound++ + } + } + if bound == 0 { + return nil + } + time, err := r.animationTime(animation, motion.Tracks, tracks, time, cache) + if err != nil { + return err + } + if err := r.reserve(uint64(len(mesh.Bones)), 64); err != nil { + return err + } + state.bones = make([]U3DBoneState, 0, len(mesh.Bones)) + for i, bone := range mesh.Bones { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if i == 0 && animation.Attributes&2 != 0 { + continue + } + if index := tracks[bone.Name]; index >= 0 { + frame, err := cache.sample(&motion.Tracks[index], time) + if err != nil { + return err + } + state.bones = append(state.bones, U3DBoneState{Name: bone.Name, Frame: frame}) + } + } + if len(state.bones) == 0 { + r.remaining += uint64(cap(state.bones)) * 64 + state.bones = nil + return nil + } + resourceEnd, nodeEnd := uint64(animation.Index), uint64(0) + if chain != 0 { + resourceEnd, nodeEnd = math.MaxUint64, uint64(animation.Index) + } + state.weights, err = r.skinWeightSet(mesh, node.BoneWeights, resourceEnd, nodeEnd) + return err +} diff --git a/ofdgo_u3d_render.go b/ofdgo_u3d_render.go index 32495f2..6656c1f 100644 --- a/ofdgo_u3d_render.go +++ b/ofdgo_u3d_render.go @@ -36,9 +36,12 @@ type U3DCamera struct { // Camera为空时使用View指定的节点或首个视图节点,Instance指定其父路径实例 // Lighting为空时沿用模型灯光,非空时替换全部模型灯光 // Opacity为空时沿用着色器混合,非空时叠加面片、着色边点及包围盒轮廓的透明度 +// 纹理合成后先应用节点透明度,再应用Opacity // Mode控制绘制方式,AuxiliaryColor用于固定颜色的边点,FaceColor用于包围盒面及插图面,空值分别使用黑色和背景色 // CreaseAngle为轮廓夹角,单位为度,空值使用45;着色插图以漫反射色的1/4补充自发光 // Nodes按名称覆盖节点,重复名称使用最后一项,每次绘制均从原场景开始 +// Time以秒指定动画采样时刻,零值为初始时刻,不随实际时钟推进 +// ResolveTexture提供外部纹理,空值不访问外部资源;回调自身的内存与超时由调用方控制 // Background为空时使用白色;缓存与实例上限为0时分别使用256MiB和65536,不含输入及运行时开销 type U3DRenderOptions struct { Width, Height int @@ -53,7 +56,9 @@ type U3DRenderOptions struct { FaceColor *[3]float64 CreaseAngle *float64 Nodes []U3DNodeState + Time float64 Sections []U3DCrossSection + ResolveTexture U3DTextureResolver MaxMemoryBytes int64 MaxInstances int } @@ -87,8 +92,15 @@ type u3dRender struct { toCamera u3dMatrix scene []u3dInstance byNode [][]int + connected []bool members []bool meshes, shaders, materials, lightIndex map[string]int + textureIndex map[string]int + viewIndex, motionIndex map[string]int + textures map[string]*image.NRGBA64 + textureSets map[[8]*U3DTextureLayer]*u3dTextureSet + resolveTexture U3DTextureResolver + view *U3DView lights []u3dRenderLight lighting *U3DLighting opacity float64 @@ -97,7 +109,10 @@ type u3dRender struct { auxiliaryColor, faceColor [3]float64 depthOnly bool primitives map[u3dPrimitiveKey]struct{} + texturedPrimitives map[u3dTexturedPrimitiveKey]struct{} states map[string]U3DNodeState + posed map[string]*U3DMesh + animated map[string]u3dAnimationState sections []u3dSection sectionScratch [2][]u3dVertex chunks [][]u3dFragment @@ -106,8 +121,16 @@ type u3dRender struct { order uint64 } -// RenderU3D 绘制静态无纹理网格、线集、点集及包围盒,返回不透明图像,不执行脚本或动画 -// 采用逐顶点光照及逐像素深度混合,缺失资源回退为空网格、灰色材质或无光源 +// RenderU3D 绘制几何及视图纹理层,返回指定时刻的不透明图像,不执行脚本 +// 可通过节点覆盖指定动作轨道与采样时间,逐次生成独立姿态,不修改原模型 +// 显式骨骼姿态按权重变换位置和法线,暂不生成骨骼链接或推导缺失权重 +// 动画修饰器支持单动作的节点和骨骼求值,尚未实现的动作衔接返回明确错误 +// 几何纹理支持模型坐标与程序化映射,参考形状约定见U3DTextureLayer +// 纹理按需解码并复用,不自动访问外部图像 +// 采用逐顶点光照及逐像素深度混合,空名称引用标准默认资源 +// 缺失几何、材质和着色器采用默认资源,缺失的非空光源引用不产生光照 +// 缺失纹理返回错误,空纹理名称使用标准默认图像 +// 仅绘制默认根的后代,非空父节点引用缺失时不展开该父路径 // 入参: ctx 取消上下文, model 场景, options 视图、输出与资源限制 // 返回: *image.RGBA 图像, error 参数、能力、预算或取消错误 func RenderU3D(ctx context.Context, model *U3DModel, options U3DRenderOptions) (*image.RGBA, error) { @@ -123,17 +146,17 @@ func RenderU3D(ctx context.Context, model *U3DModel, options U3DRenderOptions) ( } // newU3DRender 建立有预算限制的场景实例与资源索引 -// 入参: ctx 取消上下文, model 场景, options 输出与资源限制 +// 入参: ctx 取消上下文, model 场景, options 输出与资源限制, states 后续节点覆盖分组 // 返回: *u3dRender 绘制状态, error 参数、场景、预算或取消错误 -func newU3DRender(ctx context.Context, model *U3DModel, options U3DRenderOptions) (*u3dRender, error) { - if ctx == nil || model == nil || options.Width <= 0 || options.Height <= 0 || options.MaxMemoryBytes < 0 || options.MaxInstances < 0 || options.Instance < 0 { +func newU3DRender(ctx context.Context, model *U3DModel, options U3DRenderOptions, states ...[]U3DNodeState) (*u3dRender, error) { + if ctx == nil || model == nil || options.Width <= 0 || options.Height <= 0 || options.MaxMemoryBytes < 0 || options.MaxInstances < 0 || options.Instance < 0 || !finite(options.Time) { return nil, fmt.Errorf("invalid U3D render options") } limit := options.MaxMemoryBytes if limit == 0 { limit = u3dDefaultBytes } - r := &u3dRender{ctx: ctx, model: model, remaining: uint64(limit), opacity: -1, mode: options.Mode, creaseAngle: 45} + r := &u3dRender{ctx: ctx, model: model, remaining: uint64(limit), opacity: -1, mode: options.Mode, creaseAngle: 45, resolveTexture: options.ResolveTexture} if options.Mode > U3DRenderShadedIllustration { return nil, fmt.Errorf("unsupported U3D render mode") } @@ -157,23 +180,37 @@ func newU3DRender(ctx context.Context, model *U3DModel, options U3DRenderOptions if err := r.reserve(pixels, 60); err != nil { return nil, err } + if err := r.resources(); err != nil { + return nil, err + } + order, index, err := r.sceneGraph() + if err != nil { + return nil, err + } + if err := r.checkAnimations(); err != nil { + return nil, err + } maxInstances := options.MaxInstances if maxInstances == 0 { maxInstances = 65536 } - if err := r.nodeStates(options.Nodes); err != nil { + groups := append([][]U3DNodeState{options.Nodes}, states...) + if err := r.nodeStates(groups...); err != nil { return nil, err } - if err := r.instances(maxInstances); err != nil { + if err := r.prepareAnimations(options.Time); err != nil { + return nil, err + } + if err := r.instances(order, index, maxInstances); err != nil { + return nil, err + } + if err := r.poseNodes(); err != nil { return nil, err } if err := r.reserve(uint64(len(r.scene)), 1); err != nil { return nil, err } r.members = make([]bool, len(r.scene)) - if err := r.resources(); err != nil { - return nil, err - } return r, nil } @@ -223,11 +260,21 @@ func (r *u3dRender) render(options U3DRenderOptions, passes []U3DViewPass) (*ima } r.frame[i].color = background } + if r.view != nil { + if err := r.viewLayers(r.view.Backdrops); err != nil { + return nil, err + } + } for passIndex, pass := range passes { if err := r.renderPass(pass, passIndex, passIndex == len(passes)-1); err != nil { return nil, err } } + if r.view != nil { + if err := r.viewLayers(r.view.Overlays); err != nil { + return nil, err + } + } for i, pixel := range r.frame { if i%4096 == 0 { if err := r.ctx.Err(); err != nil { @@ -263,6 +310,7 @@ func (r *u3dRender) reserve(count, size uint64) error { // 入参: options 绘制选项 // 返回: []U3DViewPass 绘制轮次, error 视图或投影错误 func (r *u3dRender) selectView(options U3DRenderOptions) ([]U3DViewPass, error) { + r.view = nil r.viewport = [4]float64{0, 0, float64(options.Width), float64(options.Height)} selected := -1 for i, node := range r.model.Nodes { @@ -286,23 +334,12 @@ func (r *u3dRender) selectView(options U3DRenderOptions) ([]U3DViewPass, error) return nil, fmt.Errorf("invalid U3D view node") } view := node.View - if len(view.Backdrops) != 0 || len(view.Overlays) != 0 { - return nil, fmt.Errorf("unsupported U3D textured view layers") + if view.Attributes & ^uint32(7) != 0 { + return nil, fmt.Errorf("invalid U3D view attributes") } - found := false - for i, resource := range r.model.Views { - if i%256 == 0 { - if err := r.ctx.Err(); err != nil { - return nil, err - } - } - if resource.Name == node.Resource { - if found { - return nil, fmt.Errorf("duplicate U3D view resource") - } - passes = resource.Passes - found = true - } + r.view = view + if i, exists := r.viewIndex[node.Resource]; exists { + passes = r.model.Views[i].Passes } if options.Camera == nil { if options.Instance >= len(r.byNode[selected]) { @@ -374,10 +411,10 @@ func (r *u3dRender) selectView(options U3DRenderOptions) ([]U3DViewPass, error) return passes, nil } -// resources 建立只读资源索引,不按名称反复扫描场景 -// 返回: error 重名或预算错误 +// resources 建立只读资源索引,空名称保留给标准默认资源 +// 返回: error 名称、预算或取消错误 func (r *u3dRender) resources() error { - count := uint64(len(r.model.Meshes)) + uint64(len(r.model.Shaders)) + uint64(len(r.model.Materials)) + uint64(len(r.model.Lights)) + count := uint64(len(r.model.Meshes)) + uint64(len(r.model.Shaders)) + uint64(len(r.model.Materials)) + uint64(len(r.model.Lights)) + uint64(len(r.model.Textures)) + uint64(len(r.model.Views)) + uint64(len(r.model.Motions)) if err := r.reserve(count, 96); err != nil { return err } @@ -385,12 +422,18 @@ func (r *u3dRender) resources() error { r.shaders = make(map[string]int, len(r.model.Shaders)) r.materials = make(map[string]int, len(r.model.Materials)) r.lightIndex = make(map[string]int, len(r.model.Lights)) + r.textureIndex = make(map[string]int, len(r.model.Textures)) + r.viewIndex = make(map[string]int, len(r.model.Views)) + r.motionIndex = make(map[string]int, len(r.model.Motions)) add := func(index map[string]int, name string, i int) error { if i%256 == 0 { if err := r.ctx.Err(); err != nil { return err } } + if name == "" { + return fmt.Errorf("cannot redefine U3D default resource") + } if _, ok := index[name]; ok { return fmt.Errorf("duplicate U3D render resource %q", name) } @@ -417,6 +460,21 @@ func (r *u3dRender) resources() error { return err } } + for i, v := range r.model.Textures { + if err := add(r.textureIndex, v.Name, i); err != nil { + return err + } + } + for i, v := range r.model.Views { + if err := add(r.viewIndex, v.Name, i); err != nil { + return err + } + } + for i, v := range r.model.Motions { + if err := add(r.motionIndex, v.Name, i); err != nil { + return err + } + } return nil } @@ -494,11 +552,11 @@ func (r *u3dRender) drawScene(pass U3DViewPass, passIndex int) error { if node.Visibility > 3 { return fmt.Errorf("invalid U3D model visibility") } - mesh, ok := r.meshes[node.Resource] - if !ok { + mesh := r.nodeMesh(node) + if mesh == nil { continue } - if err := r.drawMesh(instance, node, &r.model.Meshes[mesh], pass, passIndex); err != nil { + if err := r.drawMesh(instance, node, mesh, pass, passIndex); err != nil { return err } } diff --git a/ofdgo_u3d_resource_reader.go b/ofdgo_u3d_resource_reader.go new file mode 100644 index 0000000..9aa114a --- /dev/null +++ b/ofdgo_u3d_resource_reader.go @@ -0,0 +1,141 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" + "unicode/utf8" +) + +// u3dResourceReader 读取骨骼权重与动作资源中交错的普通字段和压缩字段 +type u3dResourceReader struct { + values *u3dValues + decoder *u3dDecoder + bits *u3dBits + hist [8]u3dHistogram + charged uint64 +} + +// value 读取指定上下文的压缩字段,首次使用时初始化算术区间 +// 入参: context 压缩上下文, small 是否为8位字段 +// 返回: uint32 字段值 +func (r *u3dResourceReader) value(context int, small bool) uint32 { + if r.err() != nil { + return 0 + } + if r.decoder.plain { + if small { + return uint32(r.values.u8()) + } + return r.values.u32() + } + if r.bits == nil { + r.bits = newU3DBits(r.values.data[r.values.pos:]) + r.bits.reserve = func(count, size uint64) error { + if err := r.decoder.reserve(count, size); err != nil { + return err + } + r.charged += count * size + return nil + } + } + return r.bits.dynamicValue(&r.hist[context], small) +} + +// u32 读取普通32位字段,压缩开始后沿用当前算术区间 +// 返回: uint32 字段值 +func (r *u3dResourceReader) u32() uint32 { + if r.bits != nil { + return r.bits.u32() + } + return r.values.u32() +} + +// f32 读取有限单精度数值 +// 返回: float32 字段值 +func (r *u3dResourceReader) f32() float32 { + value := math.Float32frombits(r.u32()) + if !finite(float64(value)) && r.err() == nil { + r.values.err = fmt.Errorf("invalid U3D floating-point value") + } + return value +} + +// text 读取UTF-8名称,压缩开始后逐字节解码而不重置区间 +// 返回: string 名称 +func (r *u3dResourceReader) text() string { + if r.err() != nil { + return "" + } + if r.bits == nil { + return r.values.text() + } + length := int(r.bits.byteValue()) | int(r.bits.byteValue())<<8 + if r.err() != nil { + return "" + } + if err := r.decoder.reserve(uint64(length), 2); err != nil { + r.values.err = err + return "" + } + data := make([]byte, length) + for index := range data { + if index%1024 == 0 { + if err := r.decoder.ctx.Err(); err != nil { + r.values.err = err + return "" + } + } + data[index] = r.bits.byteValue() + } + if r.err() == nil && !utf8.Valid(data) { + r.values.err = fmt.Errorf("invalid U3D UTF-8 string") + } + return string(data) +} + +// err 返回字段或算术解码错误 +// 返回: error 读取错误 +func (r *u3dResourceReader) err() error { + if r.values.err != nil { + return r.values.err + } + if r.bits != nil { + return r.bits.err + } + return nil +} + +// done 校验末尾冲刷字段和块边界 +// 返回: error 读取或多余数据错误 +func (r *u3dResourceReader) done() error { + if err := r.err(); err != nil { + return err + } + if r.bits != nil { + if err := r.bits.finish(); err != nil { + return err + } + r.values.pos = len(r.values.data) + } + return r.values.done() +} + +// release 归还当前块压缩上下文占用的临时预算 +func (r *u3dResourceReader) release() { + r.decoder.remaining += r.charged + r.charged = 0 +} diff --git a/ofdgo_u3d_sample.go b/ofdgo_u3d_sample.go new file mode 100644 index 0000000..6c9c23d --- /dev/null +++ b/ofdgo_u3d_sample.go @@ -0,0 +1,215 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "context" + "fmt" + "math" +) + +// U3DTrackState 指定动作资源、轨道和采样时间,Time与关键帧使用相同单位 +// Loop将时间循环到首末帧之间,未启用时停留在端点,不自动解释动画修饰器的时间参数 +type U3DTrackState struct { + Motion, Track string + Time float64 + Loop bool +} + +// Sample 按时间采样轨道,不修改原始帧或按帧数量分配临时数组 +// 位移和缩放采用线性插值,旋转归一化后采用最短弧球面插值 +// 允许帧乱序,同一时刻取文件中最后一帧;循环区间不包含末端,单帧轨道始终返回该帧 +// 入参: ctx 取消上下文, time 与关键帧同单位的时间, loop 是否循环 +// 返回: U3DKeyFrame 采样姿态, error 空轨道、数值或取消错误 +func (track U3DMotionTrack) Sample(ctx context.Context, time float64, loop bool) (U3DKeyFrame, error) { + if ctx == nil || !finite(time) || len(track.Frames) == 0 { + return U3DKeyFrame{}, fmt.Errorf("invalid U3D motion sample") + } + first, last := 0, 0 + for index, frame := range track.Frames { + if index%256 == 0 { + if err := ctx.Err(); err != nil { + return U3DKeyFrame{}, err + } + } + if !finite(float64(frame.Time)) { + return U3DKeyFrame{}, fmt.Errorf("invalid U3D key frame time") + } + if frame.Time <= track.Frames[first].Time { + first = index + } + if frame.Time >= track.Frames[last].Time { + last = index + } + } + minimum, maximum := float64(track.Frames[first].Time), float64(track.Frames[last].Time) + if loop && maximum > minimum { + time = math.Mod(time-minimum, maximum-minimum) + if time < 0 { + time += maximum - minimum + } + time += minimum + } else { + time = max(minimum, min(time, maximum)) + } + lower, upper := first, last + for index, frame := range track.Frames { + if index%256 == 0 { + if err := ctx.Err(); err != nil { + return U3DKeyFrame{}, err + } + } + value := float64(frame.Time) + if value <= time && frame.Time >= track.Frames[lower].Time { + lower = index + } + if value > time && frame.Time <= track.Frames[upper].Time { + upper = index + } + } + a, b := track.Frames[lower], track.Frames[upper] + if float64(a.Time) == time { + b = a + } + qa, err := a.quaternion() + if err != nil { + return U3DKeyFrame{}, err + } + qb, err := b.quaternion() + if err != nil { + return U3DKeyFrame{}, err + } + fraction := float64(0) + if b.Time != a.Time { + fraction = (time - float64(a.Time)) / (float64(b.Time) - float64(a.Time)) + } + result := U3DKeyFrame{Time: float32(time)} + for i := range result.Displacement { + result.Displacement[i] = float32(float64(a.Displacement[i])*(1-fraction) + float64(b.Displacement[i])*fraction) + result.Scale[i] = float32(float64(a.Scale[i])*(1-fraction) + float64(b.Scale[i])*fraction) + } + var dot float64 + for i := range qa { + dot += qa[i] * qb[i] + } + if dot < 0 { + dot = -dot + for i := range qb { + qb[i] = -qb[i] + } + } + left, right := 1-fraction, fraction + if dot < .9995 { + angle := math.Acos(min(1, dot)) + divisor := math.Sin(angle) + left, right = math.Sin(left*angle)/divisor, math.Sin(right*angle)/divisor + } + var rotation [4]float64 + var length float64 + for i := range rotation { + rotation[i] = left*qa[i] + right*qb[i] + length += rotation[i] * rotation[i] + } + length = math.Sqrt(length) + for i := range result.Rotation { + result.Rotation[i] = float32(rotation[i] / length) + } + if err := ctx.Err(); err != nil { + return U3DKeyFrame{}, err + } + return result, nil +} + +// quaternion 校验姿态分量并归一化W、X、Y、Z旋转四元数 +// 返回: [4]float64 单位四元数, error 非有限或零旋转错误 +func (frame U3DKeyFrame) quaternion() ([4]float64, error) { + var rotation [4]float64 + for _, values := range [][3]float32{frame.Displacement, frame.Scale} { + for _, value := range values { + if !finite(float64(value)) { + return rotation, fmt.Errorf("invalid U3D key frame transform") + } + } + } + var length float64 + for i, value := range frame.Rotation { + rotation[i] = float64(value) + length += rotation[i] * rotation[i] + } + if !finite(length) || length == 0 { + return rotation, fmt.Errorf("invalid U3D key frame rotation") + } + length = math.Sqrt(length) + for i := range rotation { + rotation[i] /= length + } + return rotation, nil +} + +// Matrix 将关键帧位移、旋转和缩放组合成相对父项的列式仿射矩阵,不使用Time +// 返回: [16]float64 先缩放、再旋转、后平移的变换, error 姿态数值错误 +func (frame U3DKeyFrame) Matrix() ([16]float64, error) { + q, err := frame.quaternion() + if err != nil { + return [16]float64{}, err + } + w, x, y, z := q[0], q[1], q[2], q[3] + matrix := [16]float64{ + 1 - 2*(y*y+z*z), 2 * (x*y + w*z), 2 * (x*z - w*y), 0, + 2 * (x*y - w*z), 1 - 2*(x*x+z*z), 2 * (y*z + w*x), 0, + 2 * (x*z + w*y), 2 * (y*z - w*x), 1 - 2*(x*x+y*y), 0, + float64(frame.Displacement[0]), float64(frame.Displacement[1]), float64(frame.Displacement[2]), 1, + } + for axis, scale := range frame.Scale { + for component := range 3 { + matrix[axis*4+component] *= float64(scale) + } + } + return matrix, nil +} + +// trackMatrix 按资源和轨道名称采样节点变换,不推测未命名轨道的绑定关系 +// 入参: state 动作轨道状态 +// 返回: [16]float64 节点变换, error 引用、数值或取消错误 +func (r *u3dRender) trackMatrix(state U3DTrackState) ([16]float64, error) { + var track *U3DMotionTrack + if err := r.ctx.Err(); err != nil { + return [16]float64{}, err + } + if index, exists := r.motionIndex[state.Motion]; exists { + motion := &r.model.Motions[index] + for i := range motion.Tracks { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return [16]float64{}, err + } + } + if motion.Tracks[i].Name == state.Track { + if track != nil { + return [16]float64{}, fmt.Errorf("ambiguous U3D motion track %q", state.Track) + } + track = &motion.Tracks[i] + } + } + } + if track == nil { + return [16]float64{}, fmt.Errorf("missing U3D motion track %q in %q", state.Track, state.Motion) + } + frame, err := track.Sample(r.ctx, state.Time, state.Loop) + if err != nil { + return [16]float64{}, err + } + return frame.Matrix() +} diff --git a/ofdgo_u3d_scene.go b/ofdgo_u3d_scene.go index 2a64087..61d633e 100644 --- a/ofdgo_u3d_scene.go +++ b/ofdgo_u3d_scene.go @@ -174,42 +174,42 @@ func u3dTriangleNormal(points [3]u3dVector) u3dVector { return normal.unit() } -// instances 按拓扑顺序构建全部父路径实例,限制共享层级的指数展开 -// 入参: limit 最大实例数 -// 返回: error 结构、预算、实例上限或取消错误 -func (r *u3dRender) instances(limit int) error { +// sceneGraph 校验父子关系并确定拓扑顺序,只有默认根的后代接入场景 +// 返回: []int 拓扑顺序, map[string]int 节点索引, error 结构、预算或取消错误 +func (r *u3dRender) sceneGraph() ([]int, map[string]int, error) { nodes := r.model.Nodes - if err := r.reserve(uint64(len(nodes)), 160); err != nil { - return err + if err := r.reserve(uint64(len(nodes)), 161); err != nil { + return nil, nil, err } index := make(map[string]int, len(nodes)) for i, node := range nodes { if i%256 == 0 { if err := r.ctx.Err(); err != nil { - return err + return nil, nil, err } } if _, exists := index[node.Name]; exists || node.Name == "" { - return fmt.Errorf("invalid U3D render node name") + return nil, nil, fmt.Errorf("invalid U3D render node name") } switch node.Kind { case "Group", "Model", "Light", "View": default: - return fmt.Errorf("unsupported U3D render node kind") + return nil, nil, fmt.Errorf("unsupported U3D render node kind") } index[node.Name] = i } counts := make([]int, len(nodes)) children := make([][]int, len(nodes)) r.byNode = make([][]int, len(nodes)) + r.connected = make([]bool, len(nodes)) for i, node := range nodes { if err := r.reserve(uint64(len(node.Parents)), 16); err != nil { - return err + return nil, nil, err } for k, parent := range node.Parents { if k%256 == 0 { if err := r.ctx.Err(); err != nil { - return err + return nil, nil, err } } if j, ok := index[parent.Name]; ok { @@ -222,7 +222,7 @@ func (r *u3dRender) instances(limit int) error { for i, n := range counts { if i%256 == 0 { if err := r.ctx.Err(); err != nil { - return err + return nil, nil, err } } if n == 0 { @@ -231,9 +231,54 @@ func (r *u3dRender) instances(limit int) error { } for cursor := 0; cursor < len(queue); cursor++ { if err := r.ctx.Err(); err != nil { - return err + return nil, nil, err } i := queue[cursor] + for j, parent := range nodes[i].Parents { + if j%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, nil, err + } + } + if parent.Name == "" { + r.connected[i] = true + break + } + if p, exists := index[parent.Name]; exists && r.connected[p] { + r.connected[i] = true + break + } + } + for childIndex, child := range children[i] { + if childIndex%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, nil, err + } + } + counts[child]-- + if counts[child] == 0 { + queue = append(queue, child) + } + } + } + if len(queue) != len(nodes) { + return nil, nil, fmt.Errorf("cyclic U3D node parents") + } + return queue, index, nil +} + +// instances 按拓扑顺序展开接入场景的父路径,不将未解析的父节点视为默认根 +// 入参: order 拓扑顺序, index 节点索引, limit 最大实例数 +// 返回: error 变换、预算、实例上限或取消错误 +func (r *u3dRender) instances(order []int, index map[string]int, limit int) error { + nodes := r.model.Nodes + for _, i := range order { + if err := r.ctx.Err(); err != nil { + return err + } + if !r.connected[i] { + continue + } state := r.states[nodes[i].Name] for parentIndex, parent := range nodes[i].Parents { if parentIndex%256 == 0 { @@ -241,20 +286,27 @@ func (r *u3dRender) instances(limit int) error { return err } } + parents := []int{-1} + if parent.Name != "" { + j, exists := index[parent.Name] + if !exists || !r.connected[j] { + continue + } + parents = r.byNode[j] + } var local u3dMatrix for k, v := range parent.Transform { local[k] = float64(v) } + if animation, exists := r.animated[nodes[i].Name]; exists { + local = local.multiply(animation.matrix) + } if state.Matrix != nil { local = *state.Matrix } if !local.valid() { return fmt.Errorf("unsupported U3D non-affine node transform") } - parents := []int{-1} - if j, ok := index[parent.Name]; ok { - parents = r.byNode[j] - } for _, p := range parents { if len(r.scene) >= limit { return fmt.Errorf("U3D instance count exceeds limit") @@ -284,20 +336,6 @@ func (r *u3dRender) instances(limit int) error { r.scene = append(r.scene, u3dInstance{node: i, parent: p, world: world, opacity: opacity, visibility: visibility}) } } - for childIndex, child := range children[i] { - if childIndex%256 == 0 { - if err := r.ctx.Err(); err != nil { - return err - } - } - counts[child]-- - if counts[child] == 0 { - queue = append(queue, child) - } - } } - if len(queue) != len(nodes) { - return fmt.Errorf("cyclic U3D node parents") - } - return nil + return r.ctx.Err() } diff --git a/ofdgo_u3d_section.go b/ofdgo_u3d_section.go index b4d4ea2..1274191 100644 --- a/ofdgo_u3d_section.go +++ b/ofdgo_u3d_section.go @@ -44,10 +44,10 @@ func (r *u3dRender) prepareSections(sections []U3DCrossSection) error { if len(sections) == 0 { return nil } - if err := r.reserve(uint64(len(sections)), 352); err != nil { + if err := r.reserve(uint64(len(sections)), 1152); err != nil { return err } - if err := r.reserve(uint64(len(sections))+3, 112); err != nil { + if err := r.reserve(uint64(len(sections))+3, 512); err != nil { return err } r.sections = make([]u3dSection, len(sections)) @@ -140,11 +140,10 @@ func (r *u3dRender) sceneDiagonal() (float64, error) { return 0, err } node := r.model.Nodes[instance.node] - index, exists := r.meshes[node.Resource] - if node.Kind != "Model" || !exists { + mesh := r.nodeMesh(&node) + if node.Kind != "Model" || mesh == nil { continue } - mesh := &r.model.Meshes[index] for i := range len(mesh.Faces) + len(mesh.Lines) + len(mesh.Points) { _, corners := mesh.primitive(i) if i%256 == 0 { diff --git a/ofdgo_u3d_shader.go b/ofdgo_u3d_shader.go new file mode 100644 index 0000000..3e81469 --- /dev/null +++ b/ofdgo_u3d_shader.go @@ -0,0 +1,303 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image" + "math" +) + +// U3DTextureLayer 保存单个着色器纹理通道,矩阵按列排列 +// Blend依次以0至3表示相乘、相加、替换及线性混合,BlendSource为1时使用BlendConstant +// Mode依次以0至4表示模型坐标、平面、圆柱、球面及反射映射 +// Alpha控制是否使用纹理透明度,Repeat低两位分别启用横向及纵向平铺 +// 平面映射直接取参考位置的XY;圆柱以Z为纵坐标,圆柱与球面的经线从正X轴朝正Y轴递增 +// 球面纬度从正Z极到负Z极映射为0至1;反射映射使用相机空间球面环境图,图像纵坐标向下 +type U3DTextureLayer struct { + Texture string + Intensity, BlendConstant float32 + Blend, BlendSource uint8 + Mode, Repeat uint8 + Alpha bool + Transform, WrapTransform [16]float32 +} + +// u3dTextureSet 保存一次绘制内共用的有序纹理层 +type u3dTextureSet struct { + layers [8]*U3DTextureLayer + images [8]*image.NRGBA64 + wrap [8]*[16]float64 + normal u3dMatrix + mask uint8 + reflection, perspective bool +} + +// shaderTextures 校验并缓存启用的纹理,未使用的着色器不会触发图像解码 +// 入参: shader 着色器 +// 返回: *u3dTextureSet 纹理组合,空值表示无纹理, error 参数、能力、预算或取消错误 +func (r *u3dRender) shaderTextures(shader U3DShader) (*u3dTextureSet, error) { + if shader.Textures == ([8]*U3DTextureLayer{}) { + return nil, nil + } + if err := r.ctx.Err(); err != nil { + return nil, err + } + if cached := r.textureSets[shader.Textures]; cached != nil { + return cached, nil + } + if err := r.reserve(1, 512); err != nil { + return nil, err + } + set := &u3dTextureSet{layers: shader.Textures} + for i, layer := range set.layers { + if layer == nil { + continue + } + if layer.Blend > 3 || layer.BlendSource > 1 || layer.Mode > 4 || layer.Repeat > 3 || !finite(float64(layer.Intensity)) || !finite(float64(layer.BlendConstant)) { + return nil, fmt.Errorf("invalid U3D texture layer") + } + for _, matrix := range [2][16]float32{layer.Transform, layer.WrapTransform} { + for _, value := range matrix { + if !finite(float64(value)) { + return nil, fmt.Errorf("invalid U3D texture transform") + } + } + } + if layer.Mode >= 1 && layer.Mode <= 3 { + if err := r.reserve(1, 256); err != nil { + return nil, err + } + inverse, err := u3dTextureInverse(layer.WrapTransform) + if err != nil { + return nil, err + } + set.wrap[i] = &inverse + } + if layer.Mode == 4 && !set.reflection { + var ok bool + set.normal, ok = r.toCamera.inverse() + if !ok { + return nil, fmt.Errorf("invalid U3D reflection camera") + } + set.reflection, set.perspective = true, r.camera.Perspective + } + img, err := r.texture(layer.Texture) + if err != nil { + return nil, err + } + set.images[i] = img + set.mask |= 1 << i + } + if r.textureSets == nil { + r.textureSets = make(map[[8]*U3DTextureLayer]*u3dTextureSet) + } + r.textureSets[shader.Textures] = set + return set, nil +} + +// coordinates 保存模型纹理坐标或程序化映射的插值输入,避免经线两侧直接插值 +// 入参: vertex 目标顶点, mesh 几何资源, shading 属性布局, corner 属性索引, normal 世界法线 +// 返回: error 坐标、索引或维度错误 +func (s *u3dTextureSet) coordinates(vertex *u3dVertex, mesh *U3DMesh, shading U3DShading, corner U3DCorner, normal u3dVector) error { + if vertex.textureMask != 0 { + clear(vertex.texture[:]) + } + vertex.textureMask = 0 + if s == nil { + return nil + } + vertex.textureMask = s.mask + for i, layer := range s.layers { + if layer == nil { + continue + } + if layer.Mode != 0 { + if layer.Mode == 4 { + vertex.texture[i] = s.normal.normal(normal) + if vertex.texture[i] == ([3]float64{}) { + return fmt.Errorf("invalid U3D reflection normal") + } + } else { + if uint64(corner.Position) >= uint64(len(mesh.Positions)) { + return fmt.Errorf("invalid U3D texture position index") + } + for c, value := range mesh.Positions[corner.Position] { + if !finite(float64(value)) { + return fmt.Errorf("invalid U3D texture position") + } + vertex.texture[i][c] = float64(value) + } + } + continue + } + if i >= len(shading.TextureDimensions) || uint64(corner.Texture[i]) >= uint64(len(mesh.TextureCoordinates)) { + return fmt.Errorf("invalid U3D texture coordinate index") + } + dimension := shading.TextureDimensions[i] + if dimension < 1 || dimension > 4 { + return fmt.Errorf("invalid U3D texture coordinate dimensions") + } + value := [4]float64{0, 0, 0, 1} + for component := range dimension { + v := float64(mesh.TextureCoordinates[corner.Texture[i]][component]) + if !finite(v) { + return fmt.Errorf("invalid U3D texture coordinate") + } + value[component] = v + } + for component, row := range [3]int{0, 1, 3} { + total := 0.0 + for col, v := range value { + total += float64(layer.Transform[col*4+row]) * v + } + if !finite(total) { + return fmt.Errorf("invalid U3D transformed texture coordinate") + } + vertex.texture[i][component] = total + } + } + return nil +} + +// textureColor 合成纹理后恢复节点指定的透明度,再叠加视图透明度 +// 入参: textures 纹理组合, color 顶点插值颜色, coordinates 纹理输入, position 相机空间位置, override 是否指定节点透明度 +// 返回: error 纹理映射或混合错误 +func (r *u3dRender) textureColor(textures *u3dTextureSet, color *[4]float64, coordinates *[8][3]float64, position u3dVector, override bool) error { + if textures == nil { + return nil + } + opacity := color[3] + if err := textures.color(color, coordinates, position); err != nil { + return err + } + if override { + color[3] = opacity + } + if r.opacity >= 0 { + color[3] *= r.opacity + } + return nil +} + +// color 按通道顺序合成纹理,在透明度覆盖、透明测试和雾效之前执行 +// 入参: color 未预乘底色, coordinates 插值后的纹理输入, position 相机空间位置 +// 返回: error 无效投影坐标或混合结果 +func (s *u3dTextureSet) color(color *[4]float64, coordinates *[8][3]float64, position u3dVector) error { + if s == nil { + return nil + } + for i, layer := range s.layers { + if layer == nil { + continue + } + uv := coordinates[i] + if layer.Mode != 0 { + var err error + uv, err = s.generated(i, uv, position) + if err != nil { + return err + } + } + u, v := uv[0]/uv[2], uv[1]/uv[2] + if !finite(u) || !finite(v) { + return fmt.Errorf("invalid U3D projected texture coordinate") + } + sample := u3dShaderSample(s.images[i], u, v, layer.Repeat, layer.Alpha) + for c := range 3 { + sample[c] *= float64(layer.Intensity) + } + weight := sample[3] + if layer.BlendSource == 1 { + weight = min(1, max(0, float64(layer.BlendConstant))) + } + for c := range color { + switch layer.Blend { + case 0: + color[c] *= sample[c] + case 1: + color[c] += sample[c] + case 2: + color[c] = sample[c] + case 3: + color[c] = sample[c]*weight + color[c]*(1-weight) + } + if !finite(color[c]) { + return fmt.Errorf("invalid U3D texture blend result") + } + color[c] = min(1, max(0, color[c])) + } + } + return nil +} + +// u3dShaderSample 按归一化坐标采样纹理,平铺时跨边界插值,未平铺时钳制边缘 +// 全透明采样保留原始RGB,供相加、固定权重混合及透明度覆盖使用 +// 入参: texture 纹理, u 横坐标, v 纵坐标, repeat 平铺位图, alpha 是否使用透明度 +// 返回: [4]float64 未预乘颜色及透明度 +func u3dShaderSample(texture *image.NRGBA64, u, v float64, repeat uint8, alpha bool) [4]float64 { + if repeat&1 != 0 { + u -= math.Floor(u) + } else { + u = min(1, max(0, u)) + } + if repeat&2 != 0 { + v -= math.Floor(v) + } else { + v = min(1, max(0, v)) + } + w, h := texture.Rect.Dx(), texture.Rect.Dy() + x, y := u*float64(w)-.5, v*float64(h)-.5 + x0, y0 := int(math.Floor(x)), int(math.Floor(y)) + fx, fy := x-float64(x0), y-float64(y0) + wx, wy := [2]float64{1 - fx, fx}, [2]float64{1 - fy, fy} + var result [4]float64 + var transparent [3]float64 + for row := range 2 { + py := min(h-1, max(0, y0+row)) + if repeat&2 != 0 { + py = (y0 + row + h) % h + } + for col := range 2 { + px := min(w-1, max(0, x0+col)) + if repeat&1 != 0 { + px = (x0 + col + w) % w + } + c := texture.NRGBA64At(px, py) + a := 1.0 + if alpha { + a = float64(c.A) / 65535 + } + weight := wx[col] * wy[row] + if a == 0 { + transparent[0] += float64(c.R) / 65535 * weight + transparent[1] += float64(c.G) / 65535 * weight + transparent[2] += float64(c.B) / 65535 * weight + } + result[0] += float64(c.R) / 65535 * a * weight + result[1] += float64(c.G) / 65535 * a * weight + result[2] += float64(c.B) / 65535 * a * weight + result[3] += a * weight + } + } + if result[3] != 0 { + for c := range 3 { + result[c] /= result[3] + } + } else { + copy(result[:3], transparent[:]) + } + return result +} diff --git a/ofdgo_u3d_skeleton.go b/ofdgo_u3d_skeleton.go new file mode 100644 index 0000000..5141cf9 --- /dev/null +++ b/ofdgo_u3d_skeleton.go @@ -0,0 +1,103 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "io" +) + +// U3DBone 保存相对父骨骼的初始位姿,Orientation按W、X、Y、Z排列 +// Attributes标志决定链接、关节字段是否存在,以及各轴旋转约束是否启用和限幅 +// RotationConstraints按X、Y、Z轴排列,每项依次为上限、下限;未指定骨骼姿态时不变形 +type U3DBone struct { + Name, Parent string + Attributes uint32 + Length float32 + Displacement [3]float32 + Orientation [4]float32 + LinkCount uint32 + LinkLength float32 + StartJoint, EndJoint U3DBoneJoint + RotationConstraints [3][2]float32 +} + +// U3DBoneJoint 保存骨骼局部坐标系内关节椭圆的中心偏移和两轴尺寸 +type U3DBoneJoint struct { + Center, Scale [2]float32 +} + +// skeleton 按ECMA-363第9.6.1.1.6节读取骨架,三角网格、点集和线集共用 +// 入参: r 字段读取器 +// 返回: []U3DBone 按父项在前排列的骨骼, error 结构、预算或取消错误 +func (d *u3dDecoder) skeleton(r *u3dValues) ([]U3DBone, error) { + count := r.u32() + if r.err != nil { + return nil, r.err + } + if count == 0 { + return nil, nil + } + if uint64(count) > uint64(len(r.data)-r.pos)/64 { + return nil, io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 256); err != nil { + return nil, err + } + bones := make([]U3DBone, int(count)) + names := make(map[string]bool, int(count)) + for index := range bones { + if index%256 == 0 { + if err := d.ctx.Err(); err != nil { + return nil, err + } + } + bone := &bones[index] + bone.Name, bone.Parent = r.text(), r.text() + bone.Attributes, bone.Length = r.u32(), r.f32() + for component := range bone.Displacement { + bone.Displacement[component] = r.f32() + } + for component := range bone.Orientation { + bone.Orientation[component] = r.f32() + } + if bone.Attributes&1 != 0 { + bone.LinkCount, bone.LinkLength = r.u32(), r.f32() + } + if bone.Attributes&2 != 0 { + for _, joint := range []*U3DBoneJoint{&bone.StartJoint, &bone.EndJoint} { + joint.Center = [2]float32{r.f32(), r.f32()} + joint.Scale = [2]float32{r.f32(), r.f32()} + } + } + for axis := range bone.RotationConstraints { + bone.RotationConstraints[axis] = [2]float32{r.f32(), r.f32()} + } + if r.err != nil { + return nil, r.err + } + if bone.Attributes & ^uint32(255) != 0 { + return nil, fmt.Errorf("invalid U3D bone attributes") + } + if names[bone.Name] { + return nil, fmt.Errorf("duplicate U3D bone %q", bone.Name) + } + if index == 0 && bone.Parent != "" || index != 0 && !names[bone.Parent] { + return nil, fmt.Errorf("invalid U3D bone parent %q", bone.Parent) + } + names[bone.Name] = true + } + return bones, nil +} diff --git a/ofdgo_u3d_skin.go b/ofdgo_u3d_skin.go new file mode 100644 index 0000000..3077c7f --- /dev/null +++ b/ofdgo_u3d_skin.go @@ -0,0 +1,505 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" + "slices" +) + +// U3DBoneState 按骨骼名称指定相对父骨骼末端的姿态,根骨骼相对模型原点 +// Frame可由动作轨道采样得到,Time不参与变形;单位缩放需显式设为[3]float32{1, 1, 1} +// 未覆盖项沿用文件或动画求值后的局部姿态,重复名称使用最后一项,不施加逆运动学约束 +type U3DBoneState struct { + Name string + Frame U3DKeyFrame +} + +// u3dSkinBone 保存初始姿态到当前姿态的变换及其逆矩阵 +type u3dSkinBone struct { + matrix, inverse u3dMatrix + invertible bool +} + +// u3dSkinPose 保存同一几何最近求值的姿态和权重,仅用于本次绘制 +type u3dSkinPose struct { + weights *U3DBoneWeights + states []U3DBoneState + mesh *U3DMesh +} + +// nodeMesh 返回节点本次绘制使用的几何,未变形节点直接引用原资源 +// 入参: node 模型节点 +// 返回: *U3DMesh 几何,资源缺失时为空 +func (r *u3dRender) nodeMesh(node *U3DNode) *U3DMesh { + if mesh := r.posed[node.Name]; mesh != nil { + return mesh + } + if index, exists := r.meshes[node.Resource]; exists { + return &r.model.Meshes[index] + } + return nil +} + +// poseNodes 应用动画和显式骨骼姿态,同源同姿态的节点及父路径共用变形几何 +// 返回: error 骨架、权重、预算或取消错误 +func (r *u3dRender) poseNodes() error { + if len(r.states) == 0 && len(r.animated) == 0 { + return r.ctx.Err() + } + var cache map[*U3DMesh]u3dSkinPose + var cacheBytes uint64 + defer func() { r.remaining += cacheBytes }() + for i := range r.model.Nodes { + if err := r.ctx.Err(); err != nil { + return err + } + if r.connected != nil && !r.connected[i] { + continue + } + node := &r.model.Nodes[i] + states := r.states[node.Name].Bones + animation := r.animated[node.Name] + if len(states) == 0 && len(animation.bones) == 0 { + continue + } + mesh := r.nodeMesh(node) + if node.Kind != "Model" || mesh == nil { + return fmt.Errorf("missing U3D bone state geometry for %q", node.Name) + } + if r.posed == nil { + if err := r.reserve(1, 1024); err != nil { + return err + } + r.posed = make(map[string]*U3DMesh) + } + if err := r.reserve(1, 128); err != nil { + return err + } + var temporary uint64 + if len(animation.bones) != 0 { + if len(states) == 0 { + states = animation.bones + } else { + count := uint64(len(animation.bones)) + uint64(len(states)) + if err := r.reserve(count, 64); err != nil { + return err + } + temporary = count * 64 + combined := make([]U3DBoneState, len(animation.bones), int(count)) + copy(combined, animation.bones) + states = append(combined, states...) + } + } + weights := animation.weights + if weights == nil { + var err error + weights, err = r.skinWeightSource(mesh, node.BoneWeights, math.MaxUint64, math.MaxUint64) + if err != nil { + r.remaining += temporary + return err + } + } + previous, exists := cache[mesh] + shared := exists && previous.weights == weights && len(previous.states) == len(states) + if shared { + for j := range states { + if j%256 == 0 { + if err := r.ctx.Err(); err != nil { + r.remaining += temporary + return err + } + } + if states[j] != previous.states[j] { + shared = false + break + } + } + } + if shared { + r.posed[node.Name] = previous.mesh + r.remaining += temporary + continue + } + source, sets := mesh, node.BoneWeights + if animation.weights != nil { + geometry := *mesh + geometry.BoneWeights = []U3DBoneWeights{*animation.weights} + mesh, sets = &geometry, nil + } + posed, err := r.skinMesh(mesh, sets, states) + if err != nil { + r.remaining += temporary + return err + } + r.posed[node.Name] = posed + if len(r.model.Nodes) > 1 { + if cache == nil { + if err := r.reserve(1, 1024); err != nil { + return err + } + cacheBytes += 1024 + cache = make(map[*U3DMesh]u3dSkinPose) + } + if !exists { + if err := r.reserve(1, 128); err != nil { + return err + } + cacheBytes += 128 + } + if err := r.reserve(uint64(len(states)), 64); err != nil { + return err + } + cacheBytes += uint64(len(states)) * 64 + saved := make([]U3DBoneState, len(states)) + copy(saved, states) + cache[source] = u3dSkinPose{weights: weights, states: saved, mesh: posed} + released := uint64(len(previous.states)) * 64 + r.remaining += released + cacheBytes -= released + } + r.remaining += temporary + } + return r.ctx.Err() +} + +// boneTransforms 连接骨骼局部姿态,以初始姿态的逆变换将作者坐标映射到当前姿态 +// 入参: bones 按父项在前排列的骨架, states 显式姿态 +// 返回: []u3dSkinBone 变形矩阵, error 骨架、姿态或取消错误 +func (r *u3dRender) boneTransforms(bones []U3DBone, states []U3DBoneState) ([]u3dSkinBone, error) { + index := make(map[string]int, len(bones)) + for i, bone := range bones { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + if _, exists := index[bone.Name]; exists { + return nil, fmt.Errorf("duplicate U3D bone %q", bone.Name) + } + index[bone.Name] = i + } + overrides := make(map[int]U3DKeyFrame, min(len(states), len(bones))) + for i, state := range states { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + bone, exists := index[state.Name] + if !exists { + return nil, fmt.Errorf("missing U3D bone %q", state.Name) + } + overrides[bone] = state.Frame + } + bind, pose := make([]u3dMatrix, len(bones)), make([]u3dMatrix, len(bones)) + result := make([]u3dSkinBone, len(bones)) + for i, bone := range bones { + if err := r.ctx.Err(); err != nil { + return nil, err + } + if !finite(float64(bone.Length)) || bone.Length < 0 || bone.Attributes & ^uint32(255) != 0 { + return nil, fmt.Errorf("invalid U3D bone parameters") + } + if bone.Attributes&1 != 0 && bone.LinkCount != 0 { + return nil, fmt.Errorf("unsupported U3D bone link deformation") + } + initial := U3DKeyFrame{Displacement: bone.Displacement, Rotation: bone.Orientation, Scale: [3]float32{1, 1, 1}} + base, err := initial.Matrix() + if err != nil { + return nil, err + } + current := base + if frame, exists := overrides[i]; exists { + current, err = frame.Matrix() + if err != nil { + return nil, err + } + } + bind[i], pose[i] = u3dMatrix(base), u3dMatrix(current) + if i == 0 { + if bone.Parent != "" { + return nil, fmt.Errorf("invalid U3D root bone parent") + } + } else { + parent, exists := index[bone.Parent] + if !exists || parent >= i { + return nil, fmt.Errorf("invalid U3D bone parent %q", bone.Parent) + } + end := u3dIdentity() + end[12] = float64(bones[parent].Length) + bind[i] = bind[parent].multiply(end).multiply(bind[i]) + pose[i] = pose[parent].multiply(end).multiply(pose[i]) + } + inverse, ok := bind[i].inverse() + if !ok || !pose[i].valid() { + return nil, fmt.Errorf("invalid U3D bone transform") + } + result[i].matrix = pose[i].multiply(inverse) + if !result[i].matrix.valid() { + return nil, fmt.Errorf("invalid U3D bone deformation") + } + result[i].inverse, result[i].invertible = result[i].matrix.inverse() + } + return result, nil +} + +// skinWeights 选取与几何类型相符的末项权重,节点链覆盖资源链 +// 入参: mesh 原始几何, sets 节点权重 +// 返回: *U3DBoneWeights 有效权重, error 类型、引用或取消错误 +func (r *u3dRender) skinWeights(mesh *U3DMesh, sets []U3DBoneWeights) (*U3DBoneWeights, error) { + return r.skinWeightSet(mesh, sets, math.MaxUint64, math.MaxUint64) +} + +// skinWeightSource 按修饰链截止位置选取权重,不使用动画后面的权重输出 +// 入参: mesh 原始几何, sets 节点权重, resourceEnd、nodeEnd 两条链中首个不参与的索引 +// 返回: *U3DBoneWeights 权重来源,内容尚未校验, error 类型、引用或取消错误 +func (r *u3dRender) skinWeightSource(mesh *U3DMesh, sets []U3DBoneWeights, resourceEnd, nodeEnd uint64) (*U3DBoneWeights, error) { + kind := uint32(0) + for i, count := range []int{len(mesh.Faces), len(mesh.Lines), len(mesh.Points)} { + if count != 0 { + if kind != 0 { + return nil, fmt.Errorf("unsupported U3D mixed geometry deformation") + } + kind = 1 << i + } + } + var selected *U3DBoneWeights + for chain, group := range [][]U3DBoneWeights{mesh.BoneWeights, sets} { + end := resourceEnd + if chain != 0 { + end = nodeEnd + } + var last *U3DBoneWeights + for i := range group { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + if uint64(group[i].Index) < end && group[i].Attributes == kind && (last == nil || group[i].Index > last.Index) { + last = &group[i] + } + } + if last != nil { + selected = last + } + } + if selected == nil { + return nil, fmt.Errorf("unsupported U3D deformation without bone weights") + } + return selected, r.ctx.Err() +} + +// skinWeightSet 选取并校验指定修饰链位置之前的权重 +// 入参: mesh 原始几何, sets 节点权重, resourceEnd、nodeEnd 两条链中首个不参与的索引 +// 返回: *U3DBoneWeights 有效权重, error 类型、引用、数值或取消错误 +func (r *u3dRender) skinWeightSet(mesh *U3DMesh, sets []U3DBoneWeights, resourceEnd, nodeEnd uint64) (*U3DBoneWeights, error) { + selected, err := r.skinWeightSource(mesh, sets, resourceEnd, nodeEnd) + if err != nil { + return nil, err + } + if selected.Index == 0 || len(selected.Offsets) == 0 || len(selected.Offsets)-1 > len(mesh.Positions) || selected.Offsets[0] != 0 || uint64(selected.Offsets[len(selected.Offsets)-1]) != uint64(len(selected.Weights)) { + return nil, fmt.Errorf("invalid U3D bone weight offsets") + } + for i := 1; i < len(selected.Offsets); i++ { + if err := r.ctx.Err(); err != nil { + return nil, err + } + start, end := selected.Offsets[i-1], selected.Offsets[i] + if end < start || uint64(end) > uint64(len(selected.Weights)) { + return nil, fmt.Errorf("invalid U3D bone weight range") + } + var total float64 + for j, weight := range selected.Weights[start:end] { + if j%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + if uint64(weight.Bone) >= uint64(len(mesh.Bones)) || !finite(float64(weight.Weight)) || weight.Weight < 0 { + return nil, fmt.Errorf("invalid U3D bone weight") + } + total += float64(weight.Weight) + } + if start != end && math.Abs(total-1) > 1e-6 { + return nil, fmt.Errorf("invalid U3D bone weight sum") + } + } + return selected, nil +} + +// influence 返回指定位置的骨骼权重,未提供权重的位置保持不变 +// 入参: position 作者位置索引 +// 返回: []U3DBoneWeight 影响列表 +func (weights *U3DBoneWeights) influence(position uint32) []U3DBoneWeight { + if uint64(position)+1 >= uint64(len(weights.Offsets)) { + return nil + } + return weights.Weights[weights.Offsets[position]:weights.Offsets[position+1]] +} + +// skinVector 对位置或法线应用加权骨骼变换,法线在累加后统一归一化 +// 入参: value 原向量, weights 位置权重, bones 骨骼变换, normal 是否处理法线 +// 返回: [3]float32 变形结果, error 数值、奇异变换或取消错误 +func (r *u3dRender) skinVector(value [3]float32, weights []U3DBoneWeight, bones []u3dSkinBone, normal bool) ([3]float32, error) { + input := u3dVector{float64(value[0]), float64(value[1]), float64(value[2])} + for _, component := range input { + if !finite(component) { + return [3]float32{}, fmt.Errorf("invalid U3D skin vector") + } + } + if len(weights) == 0 { + return value, nil + } + var result u3dVector + for i, weight := range weights { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return [3]float32{}, err + } + } + if weight.Weight == 0 { + continue + } + bone := &bones[weight.Bone] + v := bone.matrix.point(input) + if normal { + if !bone.invertible { + return [3]float32{}, fmt.Errorf("unsupported U3D singular bone normal transform") + } + m := bone.inverse + v = u3dVector{m[0]*input[0] + m[1]*input[1] + m[2]*input[2], m[4]*input[0] + m[5]*input[1] + m[6]*input[2], m[8]*input[0] + m[9]*input[1] + m[10]*input[2]} + } + for axis := range result { + result[axis] += v[axis] * float64(weight.Weight) + } + } + for _, component := range result { + if !finite(component) { + return [3]float32{}, fmt.Errorf("invalid U3D skin result") + } + } + if normal { + result = result.unit() + } + for axis := range value { + value[axis] = float32(result[axis]) + if !finite(float64(value[axis])) { + return [3]float32{}, fmt.Errorf("U3D skin result exceeds range") + } + } + return value, nil +} + +// skinMesh 复制受变形影响的属性,按位置与法线的组合保留独立法线索引 +// 入参: mesh 原始几何, sets 节点权重, states 显式骨骼姿态 +// 返回: *U3DMesh 独立姿态几何, error 结构、能力、预算或取消错误 +func (r *u3dRender) skinMesh(mesh *U3DMesh, sets []U3DBoneWeights, states []U3DBoneState) (*U3DMesh, error) { + weights, err := r.skinWeights(mesh, sets) + if err != nil { + return nil, err + } + if err := r.reserve(uint64(len(mesh.Bones)), 768); err != nil { + return nil, err + } + if err := r.reserve(uint64(len(states)), 128); err != nil { + r.remaining += uint64(len(mesh.Bones)) * 768 + return nil, err + } + temporary := uint64(len(mesh.Bones))*768 + uint64(len(states))*128 + defer func() { r.remaining += temporary }() + bones, err := r.boneTransforms(mesh.Bones, states) + if err != nil { + return nil, err + } + if err := r.reserve(1, 768); err != nil { + return nil, err + } + if err := r.reserve(uint64(len(mesh.Positions)), 12); err != nil { + return nil, err + } + result := *mesh + result.Positions = make([][3]float32, len(mesh.Positions)) + for i, position := range mesh.Positions { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + result.Positions[i], err = r.skinVector(position, weights.influence(uint32(i)), bones, false) + if err != nil { + return nil, err + } + } + if mesh.ExcludeNormals { + return &result, r.ctx.Err() + } + bytes := uint64(len(mesh.Faces))*148 + uint64(len(mesh.Lines))*100 + uint64(len(mesh.Points))*52 + if err := r.reserve(bytes, 1); err != nil { + return nil, err + } + result.Faces, result.Lines, result.Points = slices.Clone(mesh.Faces), slices.Clone(mesh.Lines), slices.Clone(mesh.Points) + result.Normals = nil + if err := r.reserve(1, 1024); err != nil { + return nil, err + } + index := make(map[[2]uint32]uint32) + defer func() { r.remaining += 1024 + uint64(len(index))*96 }() + for i := range len(result.Faces) + len(result.Lines) + len(result.Points) { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + _, corners := result.primitive(i) + for j := range corners { + corner := &corners[j] + if uint64(corner.Position) >= uint64(len(mesh.Positions)) || uint64(corner.Normal) >= uint64(len(mesh.Normals)) { + return nil, fmt.Errorf("invalid U3D skin corner index") + } + key := [2]uint32{corner.Position, corner.Normal} + normal, exists := index[key] + if !exists { + if err := r.reserve(1, 96); err != nil { + return nil, err + } + if len(result.Normals) == cap(result.Normals) { + capacity := max(16, uint64(cap(result.Normals))*2) + if capacity > math.MaxUint32 { + return nil, fmt.Errorf("U3D skin normal count exceeds limit") + } + if err := r.reserve(capacity, 12); err != nil { + return nil, err + } + grown := make([][3]float32, len(result.Normals), int(capacity)) + copy(grown, result.Normals) + r.remaining += uint64(cap(result.Normals)) * 12 + result.Normals = grown + } + value, err := r.skinVector(mesh.Normals[corner.Normal], weights.influence(corner.Position), bones, true) + if err != nil { + return nil, err + } + normal = uint32(len(result.Normals)) + index[key] = normal + result.Normals = append(result.Normals, value) + } + corner.Normal = normal + } + } + return &result, r.ctx.Err() +} diff --git a/ofdgo_u3d_state.go b/ofdgo_u3d_state.go index 2ec5ffe..4ca03e0 100644 --- a/ofdgo_u3d_state.go +++ b/ofdgo_u3d_state.go @@ -18,34 +18,54 @@ import "fmt" // U3DNodeState 按名称覆盖节点,未指定透明度或可见性时沿父路径继承 // Opacity范围为0到1,采用标准透明混合;Matrix为相对父节点的列式仿射矩阵 -// 未匹配的名称不影响场景,矩阵为空时保留每条父路径的原始变换 +// 显式Opacity覆盖材质和纹理合成的透明度,之后仍应用视图附加透明度 +// 未匹配的名称不影响场景,Matrix和Motion均为空时保留每条父路径的原始变换 +// Motion以指定轨道替换节点变换,与Matrix互斥,不驱动骨架;显式变换优先于动画修饰器 +// Bones覆盖动画求值后的指定骨骼姿态,未指定项沿用当前局部变换,不修改共享几何 type U3DNodeState struct { Name string Opacity *float64 Visible *bool Matrix *[16]float64 + Motion *U3DTrackState + Bones []U3DBoneState } // nodeStates 建立有预算限制的覆盖索引,重复名称整体替换而不合并字段 -// 入参: states 节点覆盖 +// 入参: groups 按优先级递增排列的节点覆盖分组 // 返回: error 数值、预算或取消错误 -func (r *u3dRender) nodeStates(states []U3DNodeState) error { - if len(states) == 0 { +func (r *u3dRender) nodeStates(groups ...[]U3DNodeState) error { + var count uint64 + for _, states := range groups { + if uint64(len(states)) > ^uint64(0)-count { + return fmt.Errorf("U3D node state count exceeds limit") + } + count += uint64(len(states)) + } + if count == 0 { return nil } - if err := r.reserve(uint64(len(states)), 128); err != nil { + if err := r.reserve(count, 160); err != nil { return err } - r.states = make(map[string]U3DNodeState, len(states)) - for i, state := range states { - if i%256 == 0 { - if err := r.ctx.Err(); err != nil { - return err + r.states = make(map[string]U3DNodeState, int(count)) + for _, states := range groups { + for i, state := range states { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } } + r.states[state.Name] = state } - r.states[state.Name] = state } - for _, state := range r.states { + var samples map[U3DTrackState]*[16]float64 + defer func() { + if samples != nil { + r.remaining += 1024 + uint64(len(samples))*192 + } + }() + for name, state := range r.states { if err := r.ctx.Err(); err != nil { return err } @@ -55,6 +75,31 @@ func (r *u3dRender) nodeStates(states []U3DNodeState) error { if state.Matrix != nil && !u3dMatrix(*state.Matrix).valid() { return fmt.Errorf("invalid U3D node matrix") } + if state.Motion != nil { + if state.Matrix != nil || !finite(state.Motion.Time) { + return fmt.Errorf("invalid U3D node motion state") + } + if samples == nil { + if err := r.reserve(1, 1024); err != nil { + return err + } + samples = make(map[U3DTrackState]*[16]float64) + } + matrix := samples[*state.Motion] + if matrix == nil { + if err := r.reserve(1, 320); err != nil { + return err + } + value, err := r.trackMatrix(*state.Motion) + if err != nil { + return err + } + matrix = &value + samples[*state.Motion] = matrix + } + state.Matrix = matrix + r.states[name] = state + } } return nil } diff --git a/ofdgo_u3d_texture.go b/ofdgo_u3d_texture.go new file mode 100644 index 0000000..a0b1215 --- /dev/null +++ b/ofdgo_u3d_texture.go @@ -0,0 +1,179 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import "fmt" + +// U3DTexture 保存纹理尺寸、图像类型及各通道的图像数据 +type U3DTexture struct { + Name string + Width, Height uint32 + ImageType uint8 + Images []U3DTextureImage +} + +// U3DTextureImage 保存压缩格式、通道位图及完整压缩数据,外部图像仅保留候选URL +// Compression依次以1至4表示JPEG-24、PNG、JPEG-8和TIFF,ByteCount仅用于内置图像 +type U3DTextureImage struct { + Compression uint8 + Channels uint8 + External bool + ByteCount uint32 + Data []byte + URLs []string +} + +// u3dTextureChannels 将亮度通道展开为RGB覆盖位,保留透明度位 +// 入参: channels 标准通道位图 +// 返回: uint8 实际覆盖通道 +func u3dTextureChannels(channels uint8) uint8 { + if channels&16 != 0 { + channels = channels&^16 | 14 + } + return channels +} + +// textureDeclaration 按ECMA-363第9.8.5.1节读取纹理及通道图像声明 +// 入参: r 字段读取器, name 纹理名称 +// 返回: error 格式、预算或取消错误 +func (d *u3dDecoder) textureDeclaration(r *u3dValues, name string) error { + if _, exists := d.textures[name]; exists { + return fmt.Errorf("duplicate U3D texture %q", name) + } + texture := U3DTexture{Name: name, Height: r.u32(), Width: r.u32(), ImageType: r.u8()} + if texture.Width == 0 || texture.Height == 0 { + return fmt.Errorf("invalid U3D texture dimensions") + } + switch texture.ImageType { + case 1, 14, 15, 16, 17: + default: + return fmt.Errorf("invalid U3D texture image type") + } + count := r.u32() + if r.err != nil { + return r.err + } + if count == 0 || count > 4 || uint64(count) > uint64(len(r.data)-r.pos)/8 { + return fmt.Errorf("invalid U3D continuation image count") + } + if err := d.reserve(uint64(count), 64); err != nil { + return err + } + texture.Images = make([]U3DTextureImage, int(count)) + var covered uint8 + for n := range texture.Images { + if err := d.ctx.Err(); err != nil { + return err + } + image := &texture.Images[n] + image.Compression, image.Channels = r.u8(), r.u8() + attributes := uint16(r.u8()) | uint16(r.u8())<<8 + if r.err != nil { + return r.err + } + channels := u3dTextureChannels(image.Channels) + if image.Compression < 1 || image.Compression > 4 || image.Channels == 0 || image.Channels&^uint8(31) != 0 || attributes > 1 || covered&channels != 0 || channels&^u3dTextureChannels(texture.ImageType) != 0 { + return fmt.Errorf("invalid U3D continuation image format") + } + covered |= channels + image.External = attributes == 1 + if !image.External { + image.ByteCount = r.u32() + continue + } + count := r.u32() + if r.err != nil { + return r.err + } + if uint64(count) > uint64(len(r.data)-r.pos)/2 { + return fmt.Errorf("invalid U3D texture URL count") + } + if err := d.reserve(uint64(count), 16); err != nil { + return err + } + image.URLs = make([]string, int(count)) + for i := range image.URLs { + if err := d.ctx.Err(); err != nil { + return err + } + image.URLs[i] = r.text() + if r.err != nil { + return r.err + } + } + } + if err := r.done(); err != nil { + return err + } + if covered != u3dTextureChannels(texture.ImageType) { + return fmt.Errorf("incomplete U3D texture channels") + } + if d.textures == nil { + d.textures = make(map[string]int) + } + d.textures[name] = len(d.model.Textures) + d.model.Textures = append(d.model.Textures, texture) + return nil +} + +// textureContinuation 按声明长度合并纹理分片,不与输入共享存储 +// 入参: r 字段读取器, name 纹理名称 +// 返回: error 格式、预算或取消错误 +func (d *u3dDecoder) textureContinuation(r *u3dValues, name string) error { + index := r.u32() + if r.err != nil { + return r.err + } + texture, exists := d.textures[name] + if !exists || uint64(index) >= uint64(len(d.model.Textures[texture].Images)) { + return fmt.Errorf("invalid U3D texture continuation reference") + } + image := &d.model.Textures[texture].Images[index] + data := r.take(len(r.data) - r.pos) + if image.External || uint64(len(image.Data))+uint64(len(data)) > uint64(image.ByteCount) { + return fmt.Errorf("invalid U3D texture continuation length") + } + if image.Data == nil { + if err := d.reserve(uint64(image.ByteCount), 1); err != nil { + return err + } + image.Data = make([]byte, 0, int(image.ByteCount)) + } + for len(data) != 0 { + if err := d.ctx.Err(); err != nil { + return err + } + count := min(65536, len(data)) + image.Data = append(image.Data, data[:count]...) + data = data[count:] + } + return d.ctx.Err() +} + +// checkTextures 确认各通道的内置图像完整,不加载外部引用 +// 返回: error 缺失分片或取消错误 +func (d *u3dDecoder) checkTextures() error { + for _, texture := range d.model.Textures { + for _, image := range texture.Images { + if err := d.ctx.Err(); err != nil { + return err + } + if !image.External && uint64(len(image.Data)) != uint64(image.ByteCount) { + return fmt.Errorf("incomplete U3D texture %q", texture.Name) + } + } + } + return nil +} diff --git a/ofdgo_u3d_texture_decode.go b/ofdgo_u3d_texture_decode.go new file mode 100644 index 0000000..bb927ad --- /dev/null +++ b/ofdgo_u3d_texture_decode.go @@ -0,0 +1,281 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "io" + + "golang.org/x/image/tiff" +) + +// U3DTextureResolver 由调用方按原始URL读取外部纹理,地址解析、访问策略和读取限额由调用方控制 +// 返回的数据在本次绘制结束前不得修改;失败时尝试下一候选地址,取消及超时错误立即返回 +// 入参: ctx 取消上下文, url 绝对或相对地址 +// 返回: []byte 压缩图像数据, error 读取错误 +type U3DTextureResolver func(ctx context.Context, url string) ([]byte, error) + +// u3dImageReader 在图像解码器读取压缩数据时检查取消状态 +type u3dImageReader struct { + ctx context.Context + data *bytes.Reader +} + +// Read 读取顺序图像数据 +// 入参: p 接收缓冲 +// 返回: int 已读字节数, error 读取或取消错误 +func (r *u3dImageReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.data.Read(p) +} + +// ReadAt 读取指定偏移的图像数据 +// 入参: p 接收缓冲, offset 字节偏移 +// 返回: int 已读字节数, error 读取或取消错误 +func (r *u3dImageReader) ReadAt(p []byte, offset int64) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.data.ReadAt(p, offset) +} + +// texture 按需合成纹理并缓存,空名称使用ECMA-363第8.4.1节规定的默认纹理 +// 入参: name 纹理名称 +// 返回: *image.NRGBA64 非预乘纹理, error 资源、解码、预算或取消错误 +func (r *u3dRender) texture(name string) (*image.NRGBA64, error) { + if err := r.ctx.Err(); err != nil { + return nil, err + } + if cached := r.textures[name]; cached != nil { + return cached, nil + } + if err := r.reserve(1, 256); err != nil { + return nil, err + } + var pixels *image.NRGBA64 + if name == "" { + if err := r.reserve(64, 8); err != nil { + return nil, err + } + pixels = image.NewNRGBA64(image.Rect(0, 0, 8, 8)) + for y := range 8 { + for x := range 8 { + c := color.NRGBA64{65535, 65535, 65535, 65535} + if x/4 != y/4 { + c.G, c.B = 26214, 13107 + } + pixels.SetNRGBA64(x, y, c) + } + } + } else { + index, exists := r.textureIndex[name] + if !exists { + return nil, fmt.Errorf("missing U3D texture %q", name) + } + var err error + pixels, err = r.decodeTexture(&r.model.Textures[index]) + if err != nil { + return nil, fmt.Errorf("U3D texture %q: %w", name, err) + } + } + if r.textures == nil { + r.textures = make(map[string]*image.NRGBA64) + } + r.textures[name] = pixels + return pixels, nil +} + +// decodeTexture 合成各续图像指定的通道,保留十六位颜色与非预乘透明度 +// 入参: texture 纹理资源 +// 返回: *image.NRGBA64 合成图像, error 格式、预算或取消错误 +func (r *u3dRender) decodeTexture(texture *U3DTexture) (*image.NRGBA64, error) { + switch texture.ImageType { + case 1, 14, 15, 16, 17: + default: + return nil, fmt.Errorf("invalid image type") + } + if texture.Width == 0 || texture.Height == 0 || len(texture.Images) == 0 || len(texture.Images) > 4 { + return nil, fmt.Errorf("invalid image dimensions or channel count") + } + var covered uint8 + for _, source := range texture.Images { + if source.Compression < 1 || source.Compression > 4 { + return nil, fmt.Errorf("invalid image compression") + } + channels := u3dTextureChannels(source.Channels) + if source.Channels == 0 || source.Channels&^uint8(31) != 0 || channels&covered != 0 || channels&^u3dTextureChannels(texture.ImageType) != 0 { + return nil, fmt.Errorf("invalid image channels") + } + covered |= channels + if source.External && (r.resolveTexture == nil || len(source.URLs) == 0) { + return nil, fmt.Errorf("external texture image requires resolution") + } + if !source.External && uint64(len(source.Data)) != uint64(source.ByteCount) { + return nil, fmt.Errorf("invalid image data length") + } + } + if covered != u3dTextureChannels(texture.ImageType) { + return nil, fmt.Errorf("incomplete image channels") + } + if err := r.reserve(uint64(texture.Width)*uint64(texture.Height), 8); err != nil { + return nil, err + } + pixels := image.NewNRGBA64(image.Rect(0, 0, int(texture.Width), int(texture.Height))) + for y := range pixels.Rect.Dy() { + if err := r.ctx.Err(); err != nil { + return nil, err + } + for x := range pixels.Rect.Dx() { + if x%4096 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + pixels.SetNRGBA64(x, y, color.NRGBA64{65535, 65535, 65535, 65535}) + } + } + for _, source := range texture.Images { + if err := r.textureSource(pixels, source); err != nil { + return nil, err + } + } + return pixels, r.ctx.Err() +} + +// textureSource 读取内置通道或按顺序尝试外部地址,不修改模型中的图像声明 +// 入参: pixels 目标纹理, source 通道图像 +// 返回: error 读取、解码、预算或取消错误 +func (r *u3dRender) textureSource(pixels *image.NRGBA64, source U3DTextureImage) error { + if !source.External { + return r.textureChannels(pixels, source) + } + if r.resolveTexture == nil || len(source.URLs) == 0 { + return fmt.Errorf("external texture image requires resolution") + } + var last error + for _, address := range source.URLs { + if err := r.ctx.Err(); err != nil { + return err + } + data, err := r.resolveTexture(r.ctx, address) + if r.ctx.Err() != nil { + return r.ctx.Err() + } + if err == nil { + source.Data = data + err = r.textureChannels(pixels, source) + source.Data = nil + } + if canceled := r.ctx.Err(); canceled != nil { + return canceled + } + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + last = fmt.Errorf("external texture image %q: %w", address, err) + } + return last +} + +// textureChannels 解码并合成一幅续图像,临时预算包括像素、分块及压缩数据开销 +// 入参: pixels 目标纹理, source 通道图像 +// 返回: error 格式、预算或取消错误 +func (r *u3dRender) textureChannels(pixels *image.NRGBA64, source U3DTextureImage) error { + var config func(io.Reader) (image.Config, error) + var decode func(io.Reader) (image.Image, error) + switch source.Compression { + case 1, 3: + config, decode = jpeg.DecodeConfig, jpeg.Decode + case 2: + config, decode = png.DecodeConfig, png.Decode + case 4: + config, decode = tiff.DecodeConfig, tiff.Decode + default: + return fmt.Errorf("invalid image compression") + } + budget := r.remaining + defer func() { r.remaining = budget }() + if err := r.reserve(uint64(len(source.Data)), 4); err != nil { + return err + } + if err := r.reserve(1, 1<<20); err != nil { + return err + } + reader := &u3dImageReader{ctx: r.ctx, data: bytes.NewReader(source.Data)} + cfg, err := config(reader) + if err != nil { + return err + } + if cfg.Width != pixels.Rect.Dx() || cfg.Height != pixels.Rect.Dy() { + return fmt.Errorf("image dimensions differ from texture declaration") + } + if source.Compression == 3 && cfg.ColorModel != color.GrayModel || source.Compression == 1 && cfg.ColorModel != color.YCbCrModel { + return fmt.Errorf("invalid JPEG texture color model") + } + w, h := uint64(cfg.Width)+16, uint64(cfg.Height)+16 + if source.Compression == 4 { + w, h = max(w, 1024), max(h, 1024) + } + if err := r.reserve(w*h, 32); err != nil { + return err + } + reader.data.Reset(source.Data) + img, err := decode(reader) + if err != nil { + return err + } + if img.Bounds() != pixels.Bounds() { + return fmt.Errorf("invalid decoded image dimensions") + } + channels := u3dTextureChannels(source.Channels) + grayAlpha := source.Channels == 1 && (cfg.ColorModel == color.GrayModel || cfg.ColorModel == color.Gray16Model) + for y := range cfg.Height { + for x := range cfg.Width { + if x%4096 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + value := imageNRGBA64At(img, x, y) + current := pixels.NRGBA64At(x, y) + if channels&8 != 0 { + current.R = value.R + } + if channels&4 != 0 { + current.G = value.G + } + if channels&2 != 0 { + current.B = value.B + } + if channels&1 != 0 { + current.A = value.A + if grayAlpha { + current.A = value.R + } + } + pixels.SetNRGBA64(x, y, current) + } + } + return r.ctx.Err() +} diff --git a/ofdgo_u3d_triangle.go b/ofdgo_u3d_triangle.go index ebeae68..cd0883c 100644 --- a/ofdgo_u3d_triangle.go +++ b/ofdgo_u3d_triangle.go @@ -20,10 +20,13 @@ import ( "slices" ) -// u3dVertex 保存裁剪前后的相机坐标及未预乘颜色 +// u3dVertex 保存裁剪前后的相机坐标、未预乘颜色及纹理插值输入 type u3dVertex struct { - point u3dVector - color [4]float64 + point u3dVector + color [4]float64 + texture [8][3]float64 + textureMask uint8 + opacityOverride bool } // drawMesh 按实例复用顶点变换,着色列表按指定顺序绘制 @@ -51,8 +54,9 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, } r.primitives = make(map[u3dPrimitiveKey]struct{}) defer func() { - r.remaining += 1024 + uint64(len(r.primitives))*512 + r.remaining += 1024 + uint64(len(r.primitives))*512 + uint64(len(r.texturedPrimitives))*1024 r.primitives = nil + r.texturedPrimitives = nil }() } normalCount := 0 @@ -161,15 +165,13 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, return err } } - style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: math.MaxUint32}} + style := u3dRenderStyle{shader: U3DShader{Blend: 0x606, RenderPass: r.shaderPass(name)}} var err error if shaded { style, err = r.style(name) if err != nil { return err } - } else if index, ok := r.shaders[name]; ok { - style.shader.RenderPass = r.model.Shaders[index].RenderPass } if style.shader.RenderPass&(1<= 0 || r.opacity >= 0 { style.shader.Blend = 0x606 } + textures, err := r.shaderTextures(style.shader) + if err != nil { + return err + } for i, corner := range face.Corners { + normal := faceNormal + if shaded && !mesh.ExcludeNormals && invertible { + normal = normals[corner.Normal] + } + if !front { + for j := range normal { + normal[j] = -normal[j] + } + } + if err := textures.coordinates(&vertices[i], mesh, shading, corner, normal); err != nil { + return err + } if !shaded { vertices[i].color = [4]float64{r.auxiliaryColor[0], r.auxiliaryColor[1], r.auxiliaryColor[2], 1} if r.mode == U3DRenderIllustration { @@ -192,15 +210,6 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, } continue } - normal := faceNormal - if !mesh.ExcludeNormals && invertible { - normal = normals[corner.Normal] - } - if !front { - for j := range normal { - normal[j] = -normal[j] - } - } var diffuse, specular *[4]float32 if shading.Attributes&1 != 0 { diffuse = &mesh.Diffuse[corner.Diffuse] @@ -214,8 +223,9 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, } if instance.opacity >= 0 { vertices[i].color[3] = instance.opacity + vertices[i].opacityOverride = true } - if r.opacity >= 0 { + if r.opacity >= 0 && textures == nil { vertices[i].color[3] *= r.opacity } } @@ -235,7 +245,7 @@ func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, // 入参: triangle 三角面, shader 着色器, pass 绘制轮次 // 返回: error 数值、资源或取消错误 func (r *u3dRender) clipTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error { - if shader.Blend == 0x606 && triangle[0].color[3] == 0 && triangle[1].color[3] == 0 && triangle[2].color[3] == 0 { + if shader.Textures == ([8]*U3DTextureLayer{}) && shader.Blend == 0x606 && triangle[0].color[3] == 0 && triangle[1].color[3] == 0 && triangle[2].color[3] == 0 { return r.ctx.Err() } var storage [2][12]u3dVertex @@ -259,13 +269,7 @@ func (r *u3dRender) clipTriangle(triangle [3]u3dVertex, shader U3DShader, pass U } if (d >= 0) != (previousDistance >= 0) { t := u3dClipFraction(previousDistance, d) - var intersection u3dVertex - for i := range intersection.point { - intersection.point[i] = previous.point[i]*(1-t) + vertex.point[i]*t - } - for i := range intersection.color { - intersection.color[i] = previous.color[i]*(1-t) + vertex.color[i]*t - } + intersection := u3dInterpolate(previous, vertex, t) if output == len(storage[next]) { return fmt.Errorf("invalid U3D clipped polygon") } @@ -298,6 +302,10 @@ func (r *u3dRender) clipTriangle(triangle [3]u3dVertex, shader U3DShader, pass U // 入参: triangle 已裁剪三角面, shader 着色器, pass 绘制轮次 // 返回: error 数值、预算或取消错误 func (r *u3dRender) rasterTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error { + textures, err := r.shaderTextures(shader) + if err != nil { + return err + } var x, y, reciprocal [3]float64 for i, v := range triangle { reciprocal[i] = 1 @@ -385,13 +393,27 @@ func (r *u3dRender) rasterTriangle(triangle [3]u3dVertex, shader U3DShader, pass color[c] = triangle[0].color[c] + w1*(triangle[1].color[c]-triangle[0].color[c]) + w2*(triangle[2].color[c]-triangle[0].color[c]) } position := u3dVector{} - if pass.Attributes&1 != 0 { + if pass.Attributes&1 != 0 || textures != nil && textures.reflection { position = u3dVector{(px - r.viewport[0] - r.viewport[2]/2 - r.camera.ShiftX) / r.camera.ScaleX, (r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY - py) / r.camera.ScaleY, depth} if r.camera.Perspective { position[0] *= depth position[1] *= depth } } + if textures != nil { + var uv [8][3]float64 + for layer := range textures.layers { + if textures.mask&(1<= r.viewport[0]+r.viewport[2] || cy >= r.viewport[1]+r.viewport[3] { + continue + } + dx, dy := cx-x, cy-y + u, v := (cos*dx-sin*dy)/sx+rx, (sin*dx+cos*dy)/sy+ry + if u < 0 || v < 0 || u >= float64(texture.Rect.Dx()) || v >= float64(texture.Rect.Dy()) { + continue + } + value := u3dTextureSample(texture, u, v) + pixel := &r.frame[py*r.output.Rect.Dx()+px] + alpha := value[3] * blend + for i := range pixel.color { + pixel.color[i] = value[i]*blend + pixel.color[i]*(1-alpha) + } + } + } + } + return r.ctx.Err() +} + +// u3dTextureSample 在像素中心进行双线性采样,使用预乘颜色避免透明边缘色晕 +// 入参: texture 纹理, x 横向像素坐标, y 纵向像素坐标 +// 返回: [4]float64 预乘颜色及透明度 +func u3dTextureSample(texture *image.NRGBA64, x, y float64) [4]float64 { + x, y = x-.5, y-.5 + x0, y0 := int(math.Floor(x)), int(math.Floor(y)) + fx, fy := x-float64(x0), y-float64(y0) + wx, wy := [2]float64{1 - fx, fx}, [2]float64{1 - fy, fy} + var result [4]float64 + for row := range 2 { + for col := range 2 { + weight := wx[col] * wy[row] + c := texture.NRGBA64At(min(max(0, x0+col), texture.Rect.Max.X-1), min(max(0, y0+row), texture.Rect.Max.Y-1)) + alpha := float64(c.A) / 65535 + result[0] += float64(c.R) / 65535 * alpha * weight + result[1] += float64(c.G) / 65535 * alpha * weight + result[2] += float64(c.B) / 65535 * alpha * weight + result[3] += alpha * weight + } + } + return result +} diff --git a/ofdgo_u3d_weights.go b/ofdgo_u3d_weights.go new file mode 100644 index 0000000..6af44df --- /dev/null +++ b/ofdgo_u3d_weights.go @@ -0,0 +1,180 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "math" +) + +// U3DBoneWeight 保存某骨骼对一个位置的影响系数 +type U3DBoneWeight struct { + Bone uint32 + Weight float32 +} + +// U3DBoneWeights 保存修饰链中的权重,Index为链内位置,Attributes的1、2、4分别表示网格、线集、点集 +// 第i个位置的权重为Weights[Offsets[i]:Offsets[i+1]],空区间表示没有骨骼影响 +type U3DBoneWeights struct { + Index, Attributes uint32 + InverseQuantization float32 + Offsets []uint32 + Weights []U3DBoneWeight +} + +// boneWeights 读取ECMA-363第9.7.4节权重修饰器,末项权重由其余各项之和确定 +// 入参: values 字段读取器, name 所属链名, chain 链类型, index 链内位置 +// 返回: error 结构、数值、预算或取消错误 +func (d *u3dDecoder) boneWeights(values *u3dValues, name string, chain int, index uint32) error { + r := u3dResourceReader{values: values, decoder: d} + defer r.release() + weights := U3DBoneWeights{Index: index, Attributes: r.u32(), InverseQuantization: r.f32()} + positions := r.u32() + if err := r.err(); err != nil { + return err + } + if weights.Attributes != 1 && weights.Attributes != 2 && weights.Attributes != 4 || weights.InverseQuantization < 0 { + return fmt.Errorf("invalid U3D bone weight parameters") + } + if err := d.reserve(uint64(positions)+1, 4); err != nil { + return err + } + weights.Offsets = make([]uint32, int(positions)+1) + for position := range positions { + if err := d.ctx.Err(); err != nil { + return err + } + count := r.value(0, false) + if err := r.err(); err != nil { + return err + } + start := len(weights.Weights) + needed := uint64(start) + uint64(count) + if needed > math.MaxUint32 { + return fmt.Errorf("U3D bone weight count exceeds limit") + } + if needed > uint64(cap(weights.Weights)) { + capacity := max(needed, uint64(cap(weights.Weights))*2) + if err := d.reserve(capacity, 8); err != nil { + return err + } + grown := make([]U3DBoneWeight, int(needed), int(capacity)) + copy(grown, weights.Weights) + d.remaining += uint64(cap(weights.Weights)) * 8 + weights.Weights = grown + } else { + weights.Weights = weights.Weights[:int(needed)] + } + list := weights.Weights[start:] + for i := range list { + if i%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + list[i].Bone = r.value(1, false) + if err := r.err(); err != nil { + return err + } + } + var total float32 + for i := 0; i+1 < len(list); i++ { + if i%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + list[i].Weight = float32(float64(r.value(2, false)) * float64(weights.InverseQuantization)) + total += list[i].Weight + if err := r.err(); err != nil { + return err + } + if !finite(float64(total)) || total > 1 { + return fmt.Errorf("invalid U3D bone weight sum") + } + } + if len(list) != 0 { + list[len(list)-1].Weight = 1 - total + } + weights.Offsets[position+1] = uint32(needed) + } + if err := r.done(); err != nil { + return err + } + if chain == 1 { + d.meshes[name].mesh.BoneWeights = append(d.meshes[name].mesh.BoneWeights, weights) + } else { + node := &d.model.Nodes[d.nodes[name]] + node.BoneWeights = append(node.BoneWeights, weights) + } + return nil +} + +// checkBoneWeights 在几何和节点声明齐备后校验权重引用,允许节点引用后续资源 +// 返回: error 位置、骨骼引用或取消错误 +func (d *u3dDecoder) checkBoneWeights() error { + for _, name := range d.meshOrder { + mesh := d.meshes[name] + if err := d.checkMeshBoneWeights(mesh, mesh.mesh.BoneWeights); err != nil { + return err + } + } + for _, node := range d.model.Nodes { + if err := d.checkMeshBoneWeights(d.meshes[node.Resource], node.BoneWeights); err != nil { + return err + } + } + return nil +} + +// checkMeshBoneWeights 校验适用于指定几何类型的权重,不把未匹配的类型强加到几何上 +// 入参: mesh 几何声明,可为空, sets 权重修饰器 +// 返回: error 位置、骨骼引用或取消错误 +func (d *u3dDecoder) checkMeshBoneWeights(mesh *u3dMeshDeclaration, sets []U3DBoneWeights) error { + for _, weights := range sets { + if err := d.ctx.Err(); err != nil { + return err + } + if mesh == nil { + if len(weights.Weights) != 0 { + return fmt.Errorf("missing U3D bone weight geometry") + } + continue + } + kind := uint32(1) + if mesh.points { + kind = 4 + } else if mesh.line != nil { + kind = 2 + } + if weights.Attributes != kind { + continue + } + if uint64(len(weights.Offsets)-1) > uint64(len(mesh.mesh.Positions)) { + return fmt.Errorf("invalid U3D bone weight position count") + } + for index, weight := range weights.Weights { + if index%1024 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + if uint64(weight.Bone) >= uint64(len(mesh.mesh.Bones)) { + return fmt.Errorf("invalid U3D bone weight index") + } + } + } + return nil +} diff --git a/ofdgo_version_edit.go b/ofdgo_version_edit.go index 4385517..0e39d3b 100644 --- a/ofdgo_version_edit.go +++ b/ofdgo_version_edit.go @@ -84,7 +84,7 @@ func (r *Reader) versionFileDependencies(ctx context.Context, entry string, part // versionEditGraph 沿标准文件引用建立版本依赖图,二进制资源只记录路径 // 入参: ctx 取消上下文, entry 文档入口, parts 待修改条目, generated 本次自产页面及引用 -// 返回: *versionEditGraph 依赖图, error 读取或结构错误 +// 返回: *versionEditGraph 依赖图, error 读取、结构或取消错误 func (r *Reader) versionEditGraph(ctx context.Context, entry string, parts map[string][]byte, generated map[string]editorGeneratedReferences) (*versionEditGraph, error) { graph := &versionEditGraph{files: make(map[string]*versionEditFile), used: make(map[string]bool)} queue := []string{entry} @@ -104,12 +104,12 @@ func (r *Reader) versionEditGraph(ctx context.Context, entry string, parts map[s } if !ok { var err error - data, err = r.readFile(name) + data, err = r.readFileViewContext(ctx, name) if err != nil { return nil, err } } - root, err := parseEditorXML(data) + root, err := parseEditorXMLContext(ctx, data) if err != nil { return nil, fmt.Errorf("version file %s: %w", name, err) } @@ -119,17 +119,20 @@ func (r *Reader) versionEditGraph(ctx context.Context, entry string, parts map[s if root.name.Local == "Res" { base = root.attr("BaseLoc") } - var visit func(*editorXML) - visit = func(node *editorXML) { + var visit func(*editorXML) error + visit = func(node *editorXML) error { + if err := ctx.Err(); err != nil { + return err + } if node != root && !node.matchesOFD(node.name.Local) { - return + return nil } parent := "" if node.parent != nil { parent = node.parent.name.Local } if parent == "Extension" && node.name.Local == "Data" { - return + return nil } add := func(attribute, value string, kind int) { if strings.TrimSpace(value) == "" { @@ -154,10 +157,18 @@ func (r *Reader) versionEditGraph(ctx context.Context, entry string, parts map[s } } for _, child := range node.children { - visit(child) + if err := visit(child); err != nil { + return err + } } + return nil } - visit(root) + if err := visit(root); err != nil { + return nil, err + } + } + if err := ctx.Err(); err != nil { + return nil, err } return graph, nil } @@ -408,7 +419,7 @@ func (r *Reader) versionOutputChanges(ctx context.Context, changes map[string][] // rewrite 更新已隔离文件的引用,保留其余XML及相对路径基准 // 入参: paths 原路径到新路径的映射 -// 返回: []byte 更新后的XML, error 属性编码错误 +// 返回: []byte 更新后的XML,未改动时借用原文, error 属性编码错误 func (file *versionEditFile) rewrite(paths map[string]string) ([]byte, error) { var patches []editorXMLPatch headers := make(map[*editorXML][]versionEditLink) @@ -444,6 +455,9 @@ func (file *versionEditFile) rewrite(paths map[string]string) ([]byte, error) { patches = append(patches, editorXMLPatch{node.start, node.open, data[:root.open]}) } } + if len(patches) == 0 { + return file.data, nil + } return editorPatchXML(file.data, patches), nil } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index d9fb70a..27bfc9c 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -441,8 +441,8 @@ func (e *Editor) writePartsWithPages(write func(string, []byte, bool) error, pro return nil } -// usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池 -// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, []string 原资源文件 +// usedResources 沿图层、对象及资源依赖筛选新增资源和共享文件,不修改资源池 +// 返回: []editorResource 新增字体, []editorResource 新增图片, []ColorSpace 新增颜色空间, []string 独立资源文件 func (e *Editor) usedResources() (fonts, images []editorResource, spaces []ColorSpace, sourceFiles []string) { used := make(map[string]bool) promoted := make(map[string]bool) @@ -487,10 +487,13 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color for _, page := range e.pages { collectActionReferences(page.Actions, used) var original map[string]GraphicObject + var originalLayers map[string]string if e.source != nil { original = make(map[string]GraphicObject) + originalLayers = make(map[string]string) if source := e.source.pages[page.ID]; source != nil && source.original != nil { for _, layer := range source.original.Content.Layer { + originalLayers[layer.ID] = layer.DrawParam for _, object := range layer.Objects { original[editorObjectID(object)] = object } @@ -498,6 +501,12 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color } } for _, layer := range page.Content.Layer { + if layer.DrawParam != "" { + used[layer.DrawParam] = true + if e.source != nil && originalLayers[layer.ID] != layer.DrawParam { + promoted[layer.DrawParam] = true + } + } for _, object := range layer.Objects { before, exists := original[editorObjectID(object)] collectObjectReferences(object, used) @@ -514,14 +523,31 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color } normalizeResourceReferences(used) normalizeResourceReferences(promoted) - for i := len(e.resources) - 1; i >= 0; i-- { - resource := e.resources[i] - if resource.definition() != "" && used[resource.definition()] { - for _, id := range resource.references { - used[id] = true - if e.source != nil { - promoted[id] = true - } + definitions := make(map[string]int) + var pending []string + for index, resource := range e.resources { + if id := resource.definition(); id != "" { + definitions[id] = index + if used[id] { + pending = append(pending, id) + } + } + } + for len(pending) != 0 { + id := pending[len(pending)-1] + pending = pending[:len(pending)-1] + index, ok := definitions[id] + if !ok { + continue + } + delete(definitions, id) + for _, reference := range e.resources[index].references { + used[reference] = true + if e.source != nil { + promoted[reference] = true + } + if _, ok := definitions[reference]; ok { + pending = append(pending, reference) } } }