From 839f65e3d752a2e063e60e59b9b1718215ffea4a Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 7 Oct 2026 10:26:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E6=B8=B2=E6=9F=93):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=B8=89=E7=BB=B4=E8=BD=AC=E6=8D=A2=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91=E6=B8=B2=E6=9F=93=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ofdgo_backend.go | 8 + ofdgo_compression.go | 3 + ofdgo_compression_pattern.go | 164 +++++++++ ofdgo_compression_resources.go | 1 + ofdgo_editor.go | 1 + ofdgo_editor_batch.go | 11 +- ofdgo_editor_color.go | 38 +- ofdgo_editor_composite.go | 19 +- ofdgo_editor_composite_insert.go | 2 +- ofdgo_editor_font.go | 29 +- ofdgo_editor_reference.go | 74 +++- ofdgo_editor_resources.go | 1 + ofdgo_editor_style.go | 6 +- ofdgo_editor_vector.go | 36 +- ofdgo_image.go | 11 + ofdgo_pdf.go | 13 +- ofdgo_pdf_3d.go | 501 ++++++++++++++++++++++++++ ofdgo_pdf_3d_section.go | 70 ++++ ofdgo_pdf_annot.go | 51 ++- ofdgo_pdf_buffer.go | 9 + ofdgo_pdf_clip_group.go | 2 +- ofdgo_pdf_composite.go | 36 +- ofdgo_pdf_composite_draw.go | 3 +- ofdgo_pdf_composite_scratch.go | 49 ++- ofdgo_pdf_composite_shading.go | 3 +- ofdgo_pdf_form.go | 142 ++++++++ ofdgo_pdf_geometry.go | 19 +- ofdgo_pdf_group.go | 15 + ofdgo_pdf_media.go | 42 ++- ofdgo_pdf_object.go | 5 +- ofdgo_renderer_cache_canvas.go | 70 ++++ ofdgo_renderer_canvas.go | 24 ++ ofdgo_renderer_clip_canvas.go | 57 +-- ofdgo_renderer_geom_canvas.go | 19 + ofdgo_renderer_path_canvas.go | 5 +- ofdgo_renderer_state_canvas.go | 24 ++ ofdgo_u3d.go | 589 +++++++++++++++++++++++++++++++ ofdgo_u3d_bits.go | 301 ++++++++++++++++ ofdgo_u3d_light.go | 326 +++++++++++++++++ ofdgo_u3d_line.go | 231 ++++++++++++ ofdgo_u3d_mesh.go | 295 ++++++++++++++++ ofdgo_u3d_render.go | 443 +++++++++++++++++++++++ ofdgo_u3d_scene.go | 303 ++++++++++++++++ ofdgo_u3d_section.go | 280 +++++++++++++++ ofdgo_u3d_state.go | 73 ++++ ofdgo_u3d_triangle.go | 464 ++++++++++++++++++++++++ ofdgo_u3d_view.go | 131 +++++++ 47 files changed, 4887 insertions(+), 112 deletions(-) create mode 100644 ofdgo_compression_pattern.go create mode 100644 ofdgo_pdf_3d.go create mode 100644 ofdgo_pdf_3d_section.go create mode 100644 ofdgo_pdf_form.go create mode 100644 ofdgo_renderer_cache_canvas.go create mode 100644 ofdgo_u3d.go create mode 100644 ofdgo_u3d_bits.go create mode 100644 ofdgo_u3d_light.go create mode 100644 ofdgo_u3d_line.go create mode 100644 ofdgo_u3d_mesh.go create mode 100644 ofdgo_u3d_render.go create mode 100644 ofdgo_u3d_scene.go create mode 100644 ofdgo_u3d_section.go create mode 100644 ofdgo_u3d_state.go create mode 100644 ofdgo_u3d_triangle.go create mode 100644 ofdgo_u3d_view.go diff --git a/ofdgo_backend.go b/ofdgo_backend.go index 4abd2a8..5796814 100644 --- a/ofdgo_backend.go +++ b/ofdgo_backend.go @@ -43,6 +43,14 @@ type RasterBackend interface { Render(*RasterPage) (image.Image, error) } +// RasterBufferBackend 可选的目标缓冲绘制能力,不改变后端和采样精度 +// 目标须为原点为零、尺寸与页面一致的紧密RGBA缓冲,不得与输入图像共享数据 +// 绘制前清空目标像素,不保留目标引用;失败时目标可能包含部分结果 +type RasterBufferBackend interface { + RasterBackend + RenderInto(*RasterPage, *image.RGBA) error +} + // PageCompiler 统一页面编译、文字提取和对象度量,不得修改源数据 // 三项能力需采用相同的字体和几何语义,避免绘制、搜索与选区不一致 // renderer提供文档、字体配置和文字回调,实现中不得递归调用对应的Renderer方法 diff --git a/ofdgo_compression.go b/ofdgo_compression.go index 0938ed1..bd9456a 100644 --- a/ofdgo_compression.go +++ b/ofdgo_compression.go @@ -164,6 +164,9 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi return nil, err } } + if err := snapshot.compressPatternPaints(); err != nil { + return nil, err + } return &snapshot, nil } diff --git a/ofdgo_compression_pattern.go b/ofdgo_compression_pattern.go new file mode 100644 index 0000000..acb1e68 --- /dev/null +++ b/ofdgo_compression_pattern.go @@ -0,0 +1,164 @@ +// 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 ( + "crypto/sha256" + "reflect" + "slices" +) + +const ( + compressionPatternMinimum = 4096 + compressionPatternLimit = 4096 + compressionPatternUses = 65536 +) + +// compressionPatternPaint 保存可共享的底纹颜色,不修改输入对象 +type compressionPatternPaint struct { + fill *FillColor + stroke *StrokeColor + count int + id string +} + +// compressionPatternUse 记录输出快照内需要替换的直接对象 +type compressionPatternUse struct { + page, layer, object int + paint *compressionPatternPaint +} + +// compressPatternPaints 将新文档中重复的大底纹颜色提取为绘制参数,仅修改输出快照 +// 返回: error 编码或取消错误 +func (e *Editor) compressPatternPaints() error { + if e.output == nil || e.output.options.Mode == CompressionUnchanged || e.output.protected || e.source != nil { + return nil + } + paints := make(map[[32]byte]*compressionPatternPaint) + var uses []compressionPatternUse + for pi, page := range e.pages { + for li, layer := range page.Content.Layer { + if layer.DrawParam != "" { + continue + } + for oi, object := range layer.Objects { + if err := e.output.ctx.Err(); err != nil { + return err + } + if len(uses) == compressionPatternUses { + break + } + fill, stroke := compressionObjectPattern(object) + if fill == nil && stroke == nil { + continue + } + hash := sha256.New() + var size int64 + x := newOFDXML(convertWriter{writer: hash, count: &size, context: e.output.ctx}) + x.ctx = e.output.ctx + x.color("FillColor", fill) + x.color("StrokeColor", (*FillColor)(stroke)) + if err := x.finish(); err != nil { + return err + } + if size < compressionPatternMinimum { + continue + } + key := [32]byte(hash.Sum(nil)) + paint := paints[key] + if paint == nil { + if len(paints) == compressionPatternLimit { + continue + } + paint = &compressionPatternPaint{fill: fill, stroke: stroke} + paints[key] = paint + } else if !reflect.DeepEqual(paint.fill, fill) || !reflect.DeepEqual(paint.stroke, stroke) { + continue + } + paint.count++ + uses = append(uses, compressionPatternUse{pi, li, oi, paint}) + } + } + } + var copied bool + lastPage, lastLayer := -1, -1 + for _, use := range uses { + if err := e.output.ctx.Err(); err != nil { + return err + } + if use.paint.count < 2 { + continue + } + if !copied { + e.pages = slices.Clone(e.pages) + e.resources = slices.Clone(e.resources) + copied = true + } + if use.paint.id == "" { + id, err := e.addEditorDrawParam(DrawParam{FillColor: use.paint.fill, StrokeColor: use.paint.stroke}) + if err != nil { + return err + } + use.paint.id = id + } + if use.page != lastPage { + e.pages[use.page].Content.Layer = slices.Clone(e.pages[use.page].Content.Layer) + lastPage, lastLayer = use.page, -1 + } + layer := &e.pages[use.page].Content.Layer[use.layer] + if use.layer != lastLayer { + layer.Objects = slices.Clone(layer.Objects) + lastLayer = use.layer + } + object := &layer.Objects[use.object] + if object.Type == "PathObject" { + object.PathObject.DrawParam = use.paint.id + object.PathObject.FillColor, object.PathObject.StrokeColor = nil, nil + } else { + object.TextObject.DrawParam = use.paint.id + object.TextObject.FillColor, object.TextObject.StrokeColor = nil, nil + } + } + return nil +} + +// compressionObjectPattern 筛选没有原文及绘制参数继承的底纹对象 +// 入参: object 图形对象 +// 返回: *FillColor 填充颜色, *StrokeColor 描边颜色,不适用时均为空 +func compressionObjectPattern(object GraphicObject) (*FillColor, *StrokeColor) { + if object.origin != nil { + return nil, nil + } + var fill *FillColor + var stroke *StrokeColor + switch object.Type { + case "PathObject": + if object.PathObject.DrawParam != "" { + return nil, nil + } + fill, stroke = object.PathObject.FillColor, object.PathObject.StrokeColor + case "TextObject": + if object.TextObject.DrawParam != "" { + return nil, nil + } + fill, stroke = object.TextObject.FillColor, object.TextObject.StrokeColor + default: + return nil, nil + } + if fill != nil && fill.Pattern != nil || stroke != nil && stroke.Pattern != nil { + return fill, stroke + } + return nil, nil +} diff --git a/ofdgo_compression_resources.go b/ofdgo_compression_resources.go index 20ce667..a46bba0 100644 --- a/ofdgo_compression_resources.go +++ b/ofdgo_compression_resources.go @@ -50,6 +50,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea if e.output == nil || e.output.options.Mode == CompressionUnchanged || e.output.protected { return nil } + generated = e.generatedVectorReferences(parts, generated) names := make(map[string]bool) if reader != nil { for name := range reader.fileIndex { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index c1350fe..2b23f57 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -84,6 +84,7 @@ type editorResource struct { vectorKey [32]byte vectorStates map[string]editorCompositeState references []string + usage *editorVectorUsage subset *editorFontSubset states map[string]editorCompositeState draw *DrawParam diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index ade7ed5..4a03251 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -170,6 +170,14 @@ func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []Gra // 入参: ctx 取消上下文, page 目标页, objects 无原文和标识且调用方不再修改的对象 // 返回: error 校验或取消错误,失败时不提交页面内容 func (e *Editor) appendOwnedObjects(ctx context.Context, page int, objects []GraphicObject) error { + return (&editorValidation{Editor: e}).appendOwnedObjects(ctx, page, objects) +} + +// appendOwnedObjects 在资源不变的构建会话内复用校验结果,接收对象所有权 +// 入参: ctx 取消上下文, page 目标页, objects 无原文和标识的独立对象 +// 返回: error 校验或取消错误 +func (v *editorValidation) appendOwnedObjects(ctx context.Context, page int, objects []GraphicObject) error { + e := v.Editor if err := ctx.Err(); err != nil { return err } @@ -182,7 +190,6 @@ func (e *Editor) appendOwnedObjects(ctx context.Context, page int, objects []Gra if err := e.prepareSourceIDs(); err != nil { return err } - validation := &editorValidation{Editor: e} for i, object := range objects { if err := ctx.Err(); err != nil { return err @@ -190,7 +197,7 @@ func (e *Editor) appendOwnedObjects(ctx context.Context, page int, objects []Gra if object.origin != nil || editorObjectID(object) != "" { return fmt.Errorf("owned objects must not have identifiers or original content") } - prepared, err := validation.prepareObject("", object) + prepared, err := v.prepareObject("", object) if err != nil { return err } diff --git a/ofdgo_editor_color.go b/ofdgo_editor_color.go index c3c8618..ac2db00 100644 --- a/ofdgo_editor_color.go +++ b/ofdgo_editor_color.go @@ -39,7 +39,7 @@ type editorValidation struct { *Editor patterns map[*Pattern]bool composites map[string]bool - vectors map[string]*editorCompositeNode + vectors map[string]bool paths map[string]bool } @@ -142,7 +142,7 @@ func (e *Editor) StylePatterns(page int, ids []string, stroke bool, style Patter return err } } - objects, _, err := e.selectedObjects(page, ids) + objects, indexes, err := e.selectedObjects(page, ids) if err != nil { return err } @@ -150,17 +150,51 @@ func (e *Editor) StylePatterns(page int, ids []string, stroke bool, style Patter objects[i] = cloneEditorData(objects[i]) object := &objects[i] var fill *FillColor + var draw string switch object.Type { case "TextObject": + draw = object.TextObject.DrawParam fill = object.TextObject.FillColor if stroke { fill = (*FillColor)(object.TextObject.StrokeColor) } case "PathObject", "Path": + draw = object.PathObject.DrawParam fill = object.PathObject.FillColor if stroke { fill = (*FillColor)(object.PathObject.StrokeColor) } + default: + return fmt.Errorf("object %q does not use a pattern", ids[i]) + } + if fill == nil { + base, err := e.editorDrawParam(e.pages[page].Content.Layer[indexes[i].layer].DrawParam, make(map[string]bool)) + if err != nil { + return err + } + param, err := e.editorDrawParam(draw, make(map[string]bool)) + if err != nil { + return err + } + paint := mergeDrawParam(*base, param) + fill = paint.FillColor + if stroke { + fill = (*FillColor)(paint.StrokeColor) + } + fill = cloneEditorData(fill) + if object.Type == "TextObject" { + if stroke { + object.TextObject.StrokeColor = (*StrokeColor)(fill) + } else { + object.TextObject.FillColor = fill + } + } else if object.Type == "PathObject" || object.Type == "Path" { + if stroke { + object.PathObject.StrokeColor = (*StrokeColor)(fill) + } else { + object.PathObject.FillColor = fill + } + } } if fill == nil || fill.Pattern == nil { return fmt.Errorf("object %q does not use a pattern", ids[i]) diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 79d9aea..e648cd8 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -1097,20 +1097,17 @@ func (v *editorValidation) validateVector(id string) error { if v.composites[id] { return fmt.Errorf("cyclic composite resource %q", id) } + if v.vectors[id] { + return nil + } if v.composites == nil { v.composites = make(map[string]bool) - v.vectors = make(map[string]*editorCompositeNode) } v.composites[id] = true defer delete(v.composites, id) - node := v.vectors[id] - if node == nil { - var err error - node, err = v.compositeDefinition(id) - if err != nil { - return err - } - v.vectors[id] = node + node, err := v.compositeDefinition(id) + if err != nil { + return err } unit := node.object.CompositeGraphicUnit if node.node.attr("Width") == "" || node.node.attr("Height") == "" || !finite(unit.Width) || !finite(unit.Height) || unit.Width < 0 || unit.Height < 0 { @@ -1131,5 +1128,9 @@ func (v *editorValidation) validateVector(id string) error { return err } } + if v.vectors == nil { + v.vectors = make(map[string]bool) + } + v.vectors[id] = true return nil } diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index e45826f..caa7ae4 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -249,7 +249,7 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) { return "", err } draw.ID = e.nextID() - data, err := encodeOFDXML(func(x *ofdXML) { + data, err := e.encodeXML(func(x *ofdXML) { x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}) x.start("DrawParams", nil) attrs := ofdAttrs{} diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 9a4a9d5..cdf1a18 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -202,12 +202,15 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { composite = true origin := e.objectOrigin(editorObjectID(object)) + var data []byte + var err error if origin == nil { - return nil, nil - } - data, err := editorXMLObject(origin.data, origin.node, origin.object, object) - if err == nil { - data, err = editorXMLStandalone(data, origin.node) + data, err = e.encodeXML(func(x *ofdXML) { x.object(object, true) }) + } else { + data, err = editorXMLObject(origin.data, origin.node, origin.object, object) + if err == nil { + data, err = editorXMLStandalone(data, origin.node) + } } if err != nil { return nil, err @@ -252,18 +255,30 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) if err := progress.report("fonts", len(e.pages)+i, total); err != nil { return nil, err } - if _, safe := refs.scan(bytes.NewReader(resource.data), resource.name); !safe { + if resource.usage.matches(resource.name, resource.data) { + refs.merge(resource.usage.refs) + } else if _, safe := refs.scan(bytes.NewReader(resource.data), resource.name); !safe { return nil, nil } } for id, usage := range refs.fonts { glyphs := used[id] - if usage.unsafe || len(usage.glyphs) != 0 { + if usage.unsafe { delete(used, id) } else if glyphs != nil { for char := range usage.chars { glyphs[e.fonts[id].GlyphIndex(char)] = true } + if len(usage.glyphs) != 0 { + mapped[id] = true + for glyph := range usage.glyphs { + if glyph >= e.fonts[id].NumGlyphs() { + delete(used, id) + break + } + glyphs[glyph] = true + } + } } } } diff --git a/ofdgo_editor_reference.go b/ofdgo_editor_reference.go index 57299c2..51b9ac5 100644 --- a/ofdgo_editor_reference.go +++ b/ofdgo_editor_reference.go @@ -16,13 +16,17 @@ package ofdgo import ( "bytes" + "crypto/sha256" "encoding/xml" "maps" "strings" "unicode/utf8" ) -// editorGeneratedReferences 保存本次编码页面及其引用,不跨写出复用 +// editorVectorUsageLimit 限制单个矢量引用快照的记录数,不限制资源内容 +const editorVectorUsageLimit = 4096 + +// editorGeneratedReferences 保存本次输出条目及其引用,不跨写出复用 type editorGeneratedReferences struct { data []byte refs *editorResourceRefs @@ -39,9 +43,49 @@ func (r editorGeneratedReferences) matches(data []byte) bool { return bytes.Equal(data, r.data) } +// editorVectorUsage 保存自产矢量资源的引用快照,内容和路径变化时失效 +type editorVectorUsage struct { + name string + digest [32]byte + refs *editorResourceRefs +} + +// matches 核对资源路径和完整内容摘要,不以切片地址判断内容 +// 入参: name 当前资源路径, data 当前资源内容 +// 返回: bool 是否仍可使用编码时的引用 +func (u *editorVectorUsage) matches(name string, data []byte) bool { + return u != nil && u.name == name && u.digest == sha256.Sum256(data) +} + +// generatedVectorReferences 合并未改变的自产矢量引用,不修改调用方集合 +// 入参: parts 输出条目, generated 本次已知引用 +// 返回: map[string]editorGeneratedReferences 本次可复用引用 +func (e *Editor) generatedVectorReferences(parts map[string][]byte, generated map[string]editorGeneratedReferences) map[string]editorGeneratedReferences { + copied := false + for _, resource := range e.resources { + if e.output != nil && e.output.ctx.Err() != nil { + return generated + } + data, ok := parts[resource.name] + if !ok || !resource.usage.matches(resource.name, data) { + continue + } + if !copied { + generated = maps.Clone(generated) + if generated == nil { + generated = make(map[string]editorGeneratedReferences) + } + copied = true + } + generated[resource.name] = editorGeneratedReferences{data: data, refs: resource.usage.refs} + } + return generated +} + // editorReferenceScan 按XML事件收集资源和字体用字,未知内容保持完整扫描 type editorReferenceScan struct { refs *editorResourceRefs + objects map[string]bool name string base string stack []string @@ -53,6 +97,26 @@ type editorReferenceScan struct { seen bool } +// vectorUsage 保留完整且限额内的自产矢量引用,超限时保存流程重新扫描 +// 入参: data 本次编码内容 +// 返回: *editorVectorUsage 引用快照,无法复用时为空 +func (s *editorReferenceScan) vectorUsage(data []byte) *editorVectorUsage { + if !s.safe || !s.seen || len(s.stack) != 0 { + return nil + } + count := len(s.refs.ids) + len(s.refs.files) + len(s.refs.fonts) + for _, usage := range s.refs.fonts { + count += len(usage.chars) + len(usage.glyphs) + if count > editorVectorUsageLimit { + return nil + } + } + if count > editorVectorUsageLimit { + return nil + } + return &editorVectorUsage{name: s.name, digest: sha256.Sum256(data), refs: s.refs} +} + // accept 共用读取和自产页面的引用判断,不将对象自身标识视为引用 // 入参: token 已解析或已编码的XML事件 // 返回: bool 是否仍可完整判断引用 @@ -99,6 +163,9 @@ func (s *editorReferenceScan) accept(token xml.Token) bool { if attr.Name.Local != "ID" { s.refs.reference(s.name, s.base, attr.Name.Local, attr.Value, s.resource) } + if s.objects != nil && editorObjectReference(attr.Name.Local) { + s.objects[attr.Value] = true + } if attr.Name.Local == "Font" { usage = s.refs.fontUsage(attr.Value) if token.Name.Local != "TextObject" && token.Name.Local != "Text" { @@ -122,6 +189,11 @@ func (s *editorReferenceScan) accept(token xml.Token) bool { return false } s.refs.reference(s.name, s.base, token.Name.Local, s.text.String(), s.resource) + if s.objects != nil && (token.Name.Local == "Thumbnail" || token.Name.Local == "Substitution") { + if id := editorResourceID(s.text.String()); id != "" { + s.objects[id] = true + } + } if (token.Name.Local == "Substitution" || token.Name.Local == "Font") && strings.TrimSpace(s.text.String()) != "" { s.refs.fontUsage(s.text.String()).unsafe = true } diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index 6d1d12a..c633b83 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -60,6 +60,7 @@ func (e *Editor) compactSourceReferences(parts map[string][]byte, progress edito if len(parts) == 0 && e.revision == 0 && (e.output == nil || e.output.options.Mode == CompressionUnchanged) { return nil, nil } + generated = e.generatedVectorReferences(parts, generated) reader := e.source.reader names := make(map[string]bool) for name := range reader.fileIndex { diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 877cf9b..8a419c1 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -91,7 +91,11 @@ func (e *Editor) ApplyAppearance(page int, ids []string, appearance ObjectAppear // 返回: *DrawParam 绘制参数, error 错误信息 func (e *Editor) DrawParam(id string, resolved bool) (*DrawParam, error) { if resolved { - return e.editorDrawParam(id, make(map[string]bool)) + draw, err := e.editorDrawParam(id, make(map[string]bool)) + if err != nil { + return nil, err + } + return cloneEditorData(draw), nil } for _, resource := range e.resources { if resource.draw != nil && resource.draw.ID == id { diff --git a/ofdgo_editor_vector.go b/ofdgo_editor_vector.go index 1eef752..d6a1436 100644 --- a/ofdgo_editor_vector.go +++ b/ofdgo_editor_vector.go @@ -17,25 +17,41 @@ package ofdgo import ( "context" "encoding/xml" + "fmt" + "maps" + "slices" ) // addOwnedVector 将本库生成的独立对象注册为标准矢量资源,不保留内联复合内容 // 入参: ctx 取消上下文, objects 无原文和标识的对象, width 资源宽度, height 资源高度 // 返回: string 资源编号, error 校验、编码或取消错误 func (e *Editor) addOwnedVector(ctx context.Context, objects []GraphicObject, width, height float64) (string, error) { - validation := &editorValidation{Editor: e} + return (&editorValidation{Editor: e}).addOwnedVector(ctx, objects, width, height) +} + +// addOwnedVector 在资源不变的构建会话内登记矢量资源,复用资源校验结果 +// 入参: ctx 取消上下文, objects 无原文和标识的对象, width 资源宽度, height 资源高度 +// 返回: string 资源编号, error 校验、编码或取消错误 +func (v *editorValidation) addOwnedVector(ctx context.Context, objects []GraphicObject, width, height float64) (string, error) { + e := v.Editor + if !finite(width) || !finite(height) || width < 0 || height < 0 { + return "", fmt.Errorf("invalid vector dimensions") + } for index, object := range objects { if err := ctx.Err(); err != nil { return "", err } - prepared, err := validation.prepareObject(e.nextID(), object) + prepared, err := v.prepareObject(e.nextID(), object) if err != nil { return "", err } objects[index] = prepared } id := e.nextID() + name := e.packageName("Res/Composites/Composite_" + id + ".xml") + scan := &editorReferenceScan{refs: &editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool), fonts: make(map[string]*editorFontUsage)}, objects: make(map[string]bool), name: name, safe: true} data, err := encodeOFDXMLContext(ctx, func(x *ofdXML) { + x.references = scan x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}) x.start("CompositeGraphicUnits", nil) x.start("CompositeGraphicUnit", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, @@ -56,10 +72,20 @@ func (e *Editor) addOwnedVector(ctx context.Context, objects []GraphicObject, wi if err != nil { return "", err } - resource, err := e.compositeResource(id, data) - if err != nil { - return "", err + resource := editorResource{name: name, data: data, composite: id} + if scan.safe && scan.seen && len(scan.stack) == 0 { + resource.references = slices.Sorted(maps.Keys(scan.objects)) + resource.usage = scan.vectorUsage(data) + } else { + resource, err = e.compositeResource(id, data) + if err != nil { + return "", err + } } e.resources = append(e.resources, resource) + if v.vectors == nil { + v.vectors = make(map[string]bool) + } + v.vectors[id] = true return id, ctx.Err() } diff --git a/ofdgo_image.go b/ofdgo_image.go index 56e4b28..0dedbf6 100644 --- a/ofdgo_image.go +++ b/ofdgo_image.go @@ -431,6 +431,17 @@ func imageNRGBAAt(img image.Image, x, y int) color.NRGBA { return color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) } +// imageAlphaAt 读取十六位透明度,原生图像避免颜色装箱 +// 入参: img 图像, x 横坐标, y 纵坐标 +// 返回: uint32 十六位透明度 +func imageAlphaAt(img image.Image, x, y int) uint32 { + if source, ok := img.(image.RGBA64Image); ok { + return uint32(source.RGBA64At(x, y).A) + } + _, _, _, alpha := img.At(x, y).RGBA() + return alpha +} + // imageNRGBA64At 读取十六位非预乘像素,原生图像避免颜色装箱 // 入参: img 图片对象, x X坐标, y Y坐标 // 返回: color.NRGBA64 NRGBA64像素 diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 0a198f2..c6ba3ee 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -32,7 +32,7 @@ var ErrPDFPassword = pdfgo.ErrPassword // Password默认使用PDF安全处理器编码,PasswordUTF8启用UTF-8输入及标准密码准备,空值尝试空密码 // Strict禁止恢复缺失资源或丢失内容语义,默认在报告中记录警告 // RendererOptions复用渲染器字体来源和后端配置,Backends优先于其中的后端设置 -// RasterDPI指定局部透明效果合成精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成 +// RasterDPI指定局部合成与三维绘制精度,0沿用渲染器DPI,默认300dpi,Strict禁用局部合成 // OnProgress按open、pages、convert及write.*阶段报告进度,total为0表示总量未知 // ResolveFile读取PDF引用的外部媒体、附件和页面,默认不访问网络或本地路径 // ResolveReference可提供已解密的引用页面,目标阅读器由调用方维护 @@ -86,12 +86,15 @@ type pdfImporter struct { cmykSpace string iccSpaces map[pdfICCKey]string objects *pdfObjectBuffer + formResources *pdfFormCache + validation *editorValidation pages map[pdfgo.Reference]*pdfgo.Page pageIDs map[pdfgo.Reference]string pageIndexes map[pdfgo.Reference]int imageIDs map[pdfImageKey][]pdfImageResource imageContents *renderCache[pdfImageContentKey, pdfImageContent] attachmentIDs map[pdfAttachmentKey]string + modelAttachments map[*pdfgo.Stream]string maskClips map[*pdfgo.SoftMask]pdfgo.Path pageBox pdfgo.Rectangle pageWidth float64 @@ -308,7 +311,7 @@ func (p *pdfImporter) commitObjects() error { if err != nil { return err } - if err := p.editor.appendOwnedObjects(p.ctx, p.page, objects); err != nil { + if err := p.objectValidation().appendOwnedObjects(p.ctx, p.page, objects); err != nil { return err } p.objects = nil @@ -778,11 +781,7 @@ func (p *pdfImporter) buildTilingPattern(source *pdfgo.TilingPattern, base pdfgo return nil, fmt.Errorf("invalid PDF tiling pattern raster scale") } cell.objects, cell.pendingPath = nil, nil - visitor := pdfgo.Visitor{Path: cell.path, Text: cell.text, Image: cell.image, Reference: p.referencePage} - visitor.Group = func(mark pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { - return cell.group(mark, walk, visitor) - } - if err := source.Walk(p.ctx, base, visitor); err != nil { + if err := source.Walk(p.ctx, base, cell.visitor()); err != nil { return nil, err } if err := cell.flushPath(); err != nil { diff --git a/ofdgo_pdf_3d.go b/ofdgo_pdf_3d.go new file mode 100644 index 0000000..bbb4fb7 --- /dev/null +++ b/ofdgo_pdf_3d.go @@ -0,0 +1,501 @@ +// 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" + "errors" + "fmt" + "image" + "math" + "strings" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfModelAttachmentLimit 限制单次转换保留的共享模型索引数量 +const pdfModelAttachmentLimit = 1024 + +// PDFThreeDRenderOptions 控制PDF三维视口的静态绘制,不改变注解激活状态 +// 缓存与实例上限为0时采用U3DRenderOptions的默认值 +type PDFThreeDRenderOptions struct { + Width, Height int + MaxMemoryBytes int64 + MaxInstances int +} + +// threeDAnnotation 按非交互呈现规则处理激活状态,保留原始模型与附件动作 +// 入参: ctx 取消上下文, page PDF页面, annotation 三维注解 +// 返回: error 外观、资源或取消错误 +func (p *pdfImporter) threeDAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation) error { + source, err := p.reader.ReadThreeD(annotation) + if err != nil { + return p.interactiveAppearanceFallback(ctx, page, annotation, "metadata", err) + } + var data []byte + id := p.modelAttachments[source.Stream] + if id == "" { + data, err = source.Stream.DecodeContext(ctx) + if err != nil { + return p.interactiveAppearanceFallback(ctx, page, annotation, "model", err) + } + id, err = p.editor.AddAttachment(fmt.Sprintf("Model_%d.%s", p.page+1, strings.ToLower(string(source.Format))), data) + if err != nil { + return err + } + if p.modelAttachments == nil { + p.modelAttachments = make(map[*pdfgo.Stream]string) + } + if len(p.modelAttachments) < pdfModelAttachmentLimit { + p.modelAttachments[source.Stream] = id + } + } + action := Action{Event: "CLICK", GotoA: &GotoA{AttachID: id}} + var paint func(*pdfImporter) error + if source.ActivationPolicy.Activation != "XA" { + appearance, err := p.reader.ReadAnnotationAppearance(annotation) + if err != nil { + return p.interactiveAppearanceFallback(ctx, page, annotation, "appearance", err) + } + if appearance == nil { + return fmt.Errorf("missing PDF 3D normal appearance") + } + if data == nil { + data, err = p.editor.source.reader.AttachmentData(id) + if err != nil { + return err + } + } + paint, err = p.threeDPainter(ctx, source, annotation, data) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return err + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D rendering unavailable; normal appearance retained: " + err.Error()}) + } + } + if err := p.paintedAnnotation(ctx, page, annotation, paint, action); err != nil { + return err + } + message := "PDF 3D appearance and original model retained; advanced interaction not transferred to OFD" + if paint != nil { + message = "PDF 3D static view and original model retained; interactive rendering not transferred to OFD" + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: message}) + return nil +} + +// threeDPainter 准备三维静态图像,复用库层压缩,失败时不提交注解对象 +// 入参: ctx 取消上下文, source 三维参数, annotation 注解, data 原始模型 +// 返回: func(*pdfImporter) error 绘制回调, error 模型、绘制或资源错误 +func (p *pdfImporter) threeDPainter(ctx context.Context, source pdfgo.ThreeD, annotation pdfgo.Annotation, data []byte) (func(*pdfImporter) error, error) { + box := source.ViewBox + width, height := box.XMax-box.XMin, box.YMax-box.YMin + x, y := annotation.Rect.XMin/2+annotation.Rect.XMax/2, annotation.Rect.YMin/2+annotation.Rect.YMax/2 + var nodes []pdfCompositeNode + if source.Background.RGB == nil { + return nil, &pdfgo.UnsupportedError{Feature: "3D background color space"} + } + if source.Background.EntireAnnotation { + r := annotation.Rect + path := pdfgo.Path{Segments: []pdfgo.Segment{ + {Operator: "M", Points: []pdfgo.Point{{X: r.XMin, Y: r.YMin}}}, + {Operator: "L", Points: []pdfgo.Point{{X: r.XMax, Y: r.YMin}}}, + {Operator: "L", Points: []pdfgo.Point{{X: r.XMax, Y: r.YMax}}}, + {Operator: "L", Points: []pdfgo.Point{{X: r.XMin, Y: r.YMax}}}, + {Operator: "C"}, + }} + nodes = append(nodes, pdfCompositeNode{path: &pdfgo.PathMark{Path: path, Fill: true, Style: pdfgo.Style{Fill: pdfgo.Paint{RGB: *source.Background.RGB, Alpha: 1}}}}) + } + if width > 0 && height > 0 { + if source.Format != "U3D" { + return nil, &pdfgo.UnsupportedError{Feature: "3D model format " + string(source.Format)} + } + model, err := DecodeU3D(ctx, data, U3DOptions{}) + if err != nil { + return nil, err + } + widthMM := width * math.Hypot(p.matrix[0], p.matrix[1]) + heightMM := height * math.Hypot(p.matrix[2], p.matrix[3]) + w, h := math.Ceil(widthMM*p.rasterDPI/25.4), math.Ceil(heightMM*p.rasterDPI/25.4) + 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)}) + if err != nil { + return nil, err + } + resource, err := p.reader.CreateImage(ctx, img) + if err != nil { + return nil, err + } + nodes = append(nodes, pdfCompositeNode{image: &pdfgo.ImageMark{Image: resource, Matrix: pdfgo.Matrix{width, 0, 0, height, x + box.XMin, y + box.YMin}, Style: pdfgo.Style{Fill: pdfgo.Paint{Alpha: 1}}}}) + } + return func(stamp *pdfImporter) error { + if err := ctx.Err(); err != nil { + return err + } + return stamp.compositeObjects(nodes) + }, nil +} + +// RenderPDFThreeD 按PDF相机、投影、背景与灯光绘制3DB区域,不执行脚本或动画 +// 返回图像不含3DB以外的注解背景,未支持的视图效果返回明确错误 +// 每次从传入场景建立独立节点状态,不继承此前绘制的运行时状态 +// 入参: ctx 取消上下文, model 解码后的U3D模型, source PDF三维参数, options 输出与资源限制 +// 返回: *image.RGBA 不透明图像, error 参数、能力、预算或取消错误 +func RenderPDFThreeD(ctx context.Context, model *U3DModel, source pdfgo.ThreeD, options PDFThreeDRenderOptions) (*image.RGBA, error) { + if ctx == nil { + return nil, fmt.Errorf("invalid PDF 3D render context") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if source.Format != "U3D" { + return nil, &pdfgo.UnsupportedError{Feature: "3D model format " + string(source.Format)} + } + presentation := source.Presentation + if presentation.RenderMode != "" && presentation.RenderMode != "Solid" || presentation.Overlay != nil { + return nil, &pdfgo.UnsupportedError{Feature: "3D view render mode or overlay"} + } + if source.Background.RGB == nil { + return nil, &pdfgo.UnsupportedError{Feature: "3D background color space"} + } + box := source.ViewBox + width, height := box.XMax-box.XMin, box.YMax-box.YMin + if !finite(width) || !finite(height) || width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid PDF 3D viewport") + } + projection := pdfgo.ThreeDProjection{Subtype: "P", Clipping: "ANF", FieldOfView: 90, PerspectiveBinding: "W"} + 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} + if err := pdfThreeDNodeStates(ctx, presentation.Nodes, &render); err != nil { + return nil, err + } + if err := pdfThreeDSections(ctx, presentation.CrossSections, &render); err != nil { + return nil, err + } + r, err := newU3DRender(ctx, model, render) + if err != nil { + return nil, err + } + camera, view, err := r.pdfCamera(source.Camera) + if err != nil { + return nil, err + } + render.View = view + camera.ScaleX, camera.ScaleY, camera.Near = 1, 1, 1 + render.Camera = &camera + passes, err := r.selectView(render) + if err != nil { + return nil, err + } + if err := r.prepareSections(render.Sections); err != nil { + return nil, err + } + scale := 0.0 + switch projection.Subtype { + case "P": + if !finite(projection.FieldOfView) || projection.FieldOfView <= 0 || projection.FieldOfView >= 180 { + return nil, &pdfgo.UnsupportedError{Feature: "degenerate 3D perspective field of view"} + } + diameter := projection.PerspectiveDiameter + if projection.PerspectiveBinding != "" { + diameter, err = pdfThreeDBinding(projection.PerspectiveBinding, width, height) + if err != nil { + return nil, err + } + } + if !finite(diameter) || diameter <= 0 { + return nil, fmt.Errorf("invalid PDF 3D perspective diameter") + } + scale = diameter / (2 * math.Tan(projection.FieldOfView*math.Pi/360)) + camera.Perspective = true + case "O": + width := source.AnnotationBox.XMax - source.AnnotationBox.XMin + height := source.AnnotationBox.YMax - source.AnnotationBox.YMin + binding, err := pdfThreeDBinding(projection.OrthographicBinding, width, height) + if err != nil { + return nil, err + } + scale = projection.OrthographicScale * binding + default: + return nil, fmt.Errorf("invalid PDF 3D projection subtype") + } + camera.ScaleX = scale * float64(options.Width) / width + camera.ScaleY = scale * float64(options.Height) / height + switch projection.Clipping { + case "XNF": + camera.Near = 0 + if projection.Near != nil { + camera.Near = *projection.Near + } + if !finite(camera.Near) || camera.Near < 0 || camera.Perspective && camera.Near == 0 { + return nil, fmt.Errorf("invalid PDF 3D near distance") + } + if projection.Far != nil { + camera.ClipFar = true + camera.Far = *projection.Far + if !finite(camera.Far) { + return nil, fmt.Errorf("invalid PDF 3D far distance") + } + if camera.Far < camera.Near { + passes, camera.Far = nil, 0 + camera.ClipFar = false + } + } + case "ANF": + minimum, maximum, found, err := r.depthRange() + if err != nil { + return nil, err + } + if !found { + passes = nil + } + camera.Near = minimum + if camera.Perspective { + if maximum <= 0 { + passes = nil + } + camera.Near = max(1e-300, maximum*1e-9) + if minimum > 0 { + camera.Near = max(1e-300, minimum/2) + } + } + camera.Far = math.Nextafter(maximum, math.Inf(1)) + if !finite(camera.Far) || camera.Far <= camera.Near { + camera.Far = 0 + } + default: + return nil, fmt.Errorf("invalid PDF 3D clipping style") + } + if _, err := r.selectView(render); err != nil { + return nil, err + } + if source.Lighting.Subtype != "Artwork" && source.Lighting.Subtype != "" { + if err := r.reserve(uint64(len(source.Lighting.Lights)), 56); err != nil { + return nil, err + } + render.Lighting = &U3DLighting{DiffuseOffset: source.Lighting.Ambient, Lights: make([]U3DDirectionalLight, len(source.Lighting.Lights))} + for i, light := range source.Lighting.Lights { + if i%256 == 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + } + direction := light.Direction + if light.CameraAttached { + direction = [3]float64{0, 0, 1} + } + render.Lighting.Lights[i] = U3DDirectionalLight{Color: light.Color, Direction: direction, CameraRelative: light.CameraAttached} + } + } + return r.render(render, passes) +} + +// pdfThreeDBinding 计算目标区域的标准绑定倍率,不按模型包围盒重新适配 +// 入参: binding 绑定方式, width 区域宽度, height 区域高度 +// 返回: float64 倍率, error 方式或尺寸错误 +func pdfThreeDBinding(binding pdfgo.Name, width, height float64) (float64, error) { + if binding == "Absolute" { + return 1, nil + } + if !finite(width) || !finite(height) || width <= 0 || height <= 0 { + return 0, fmt.Errorf("invalid PDF 3D binding dimensions") + } + switch binding { + case "W": + return width, nil + case "H": + return height, nil + case "Min": + return min(width, height), nil + case "Max": + return max(width, height), nil + } + return 0, fmt.Errorf("invalid PDF 3D binding") +} + +// pdfCamera 解析PDF相机来源并保留模型视图的绘制轮次 +// 入参: source 相机来源 +// 返回: U3DCamera 相机变换, string 视图节点, error 路径或变换错误 +func (r *u3dRender) pdfCamera(source pdfgo.ThreeDCamera) (U3DCamera, string, error) { + var camera U3DCamera + selected := -1 + for i, node := range r.model.Nodes { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return camera, "", err + } + } + if node.Kind == "View" { + selected = i + break + } + } + if source.Source == "M" { + m := source.Matrix + camera.ToWorld = [16]float64{m[0], m[1], m[2], 0, m[3], m[4], m[5], 0, m[6], m[7], m[8], 0, m[9], m[10], m[11], 1} + } else { + if source.Source != "" && source.Source != "U3D" || source.Source == "U3D" && len(source.Path) == 0 { + return camera, "", fmt.Errorf("invalid PDF 3D camera source") + } + if source.Source == "U3D" { + selected = -1 + for i, node := range r.model.Nodes { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return camera, "", err + } + } + if node.Name == source.Path[len(source.Path)-1] { + selected = i + break + } + } + } + if selected < 0 || r.model.Nodes[selected].Kind != "View" { + return camera, "", fmt.Errorf("missing PDF 3D camera node") + } + found := false + for _, index := range r.byNode[selected] { + if err := r.ctx.Err(); err != nil { + return camera, "", err + } + current, match := index, true + for j := len(source.Path) - 1; j >= 0; j-- { + if j%256 == 0 { + if err := r.ctx.Err(); err != nil { + return camera, "", err + } + } + if current < 0 || r.model.Nodes[r.scene[current].node].Name != source.Path[j] || r.model.Nodes[r.scene[current].node].Kind != "View" { + match = false + break + } + current = r.scene[current].parent + } + if source.Source == "U3D" && current >= 0 { + match = false + } + if !match { + continue + } + if found { + return camera, "", &pdfgo.UnsupportedError{Feature: "ambiguous 3D camera instance"} + } + camera.ToWorld, found = r.scene[index].world, true + } + if !found { + return camera, "", fmt.Errorf("missing PDF 3D camera path") + } + } + view := "" + if selected >= 0 { + view = r.model.Nodes[selected].Name + } + return camera, view, nil +} + +// depthRange 计算可见模型面的相机深度范围,不为自动裁剪复制顶点 +// 返回: float64 最近深度, float64 最远深度, bool 是否含几何, error 几何或取消错误 +func (r *u3dRender) depthRange() (float64, float64, bool, error) { + minimum, maximum := math.Inf(1), math.Inf(-1) + for i, instance := range r.scene { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return 0, 0, false, err + } + } + node := r.model.Nodes[instance.node] + index, ok := r.meshes[node.Resource] + if node.Kind != "Model" || instance.modelVisibility(&node) == 0 || instance.opacity == 0 || !ok { + continue + } + mesh := &r.model.Meshes[index] + matrix := r.toCamera.multiply(instance.world) + for j, face := range mesh.Faces { + if j%256 == 0 { + if err := r.ctx.Err(); err != nil { + return 0, 0, false, err + } + } + for _, corner := range face.Corners { + if uint64(corner.Position) >= uint64(len(mesh.Positions)) { + return 0, 0, false, fmt.Errorf("invalid U3D position index") + } + p := mesh.Positions[corner.Position] + z := matrix[2]*float64(p[0]) + matrix[6]*float64(p[1]) + matrix[10]*float64(p[2]) + matrix[14] + if !finite(z) { + return 0, 0, false, fmt.Errorf("invalid U3D camera depth") + } + minimum, maximum = min(minimum, z), max(maximum, z) + } + } + } + for i, section := range r.sections { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return 0, 0, false, err + } + } + if section.opacity > 0 { + for _, corner := range section.corners { + minimum, maximum = min(minimum, corner.point[2]), max(maximum, corner.point[2]) + } + } + } + if math.IsInf(minimum, 1) { + return 0, 0, false, nil + } + return minimum, maximum, true, nil +} + +// pdfThreeDNodeStates 将PDF节点参数转为场景覆盖,转换缓存计入绘制预算 +// 入参: ctx 取消上下文, nodes 节点参数, options 绘制选项 +// 返回: error 预算或取消错误 +func pdfThreeDNodeStates(ctx context.Context, nodes []pdfgo.ThreeDNode, options *U3DRenderOptions) error { + if len(nodes) == 0 { + return nil + } + limit := options.MaxMemoryBytes + if limit == 0 { + limit = u3dDefaultBytes + } + if limit <= 0 || uint64(len(nodes)) >= uint64(limit)/168 { + return fmt.Errorf("PDF 3D node memory exceeds limit") + } + options.MaxMemoryBytes = limit - int64(len(nodes))*168 + options.Nodes = make([]U3DNodeState, len(nodes)) + for i, node := range nodes { + if i%256 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + state := U3DNodeState{Name: node.Name, Opacity: node.Opacity, Visible: node.Visible} + if node.Matrix != nil { + m := node.Matrix + state.Matrix = &[16]float64{m[0], m[1], m[2], 0, m[3], m[4], m[5], 0, m[6], m[7], m[8], 0, m[9], m[10], m[11], 1} + } + options.Nodes[i] = state + } + return nil +} diff --git a/ofdgo_pdf_3d_section.go b/ofdgo_pdf_3d_section.go new file mode 100644 index 0000000..1d40fc6 --- /dev/null +++ b/ofdgo_pdf_3d_section.go @@ -0,0 +1,70 @@ +// 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" + + "github.com/xiaoqidun/pdfgo" +) + +// pdfThreeDSections 按XYZ顺序构造剖切方向,中心保持为世界坐标固定点 +// 入参: ctx 取消上下文, sections PDF剖切面, options 绘制选项 +// 返回: error 方向、预算或取消错误 +func pdfThreeDSections(ctx context.Context, sections []pdfgo.ThreeDCrossSection, options *U3DRenderOptions) error { + if len(sections) == 0 { + return nil + } + limit := options.MaxMemoryBytes + if limit == 0 { + limit = u3dDefaultBytes + } + if limit <= 0 || uint64(len(sections)) >= uint64(limit)/136 { + return fmt.Errorf("PDF 3D section memory exceeds limit") + } + options.MaxMemoryBytes = limit - int64(len(sections))*136 + options.Sections = make([]U3DCrossSection, len(sections)) + for i, source := range sections { + if err := ctx.Err(); err != nil { + return err + } + if source.Axis < 0 || source.Axis > 2 { + return fmt.Errorf("invalid PDF 3D section axis") + } + section := U3DCrossSection{Center: source.Center, Color: source.Color, Opacity: source.Opacity} + section.AxisU[(source.Axis+1)%3], section.AxisV[(source.Axis+2)%3] = 1, 1 + for axis, angle := range source.Rotation { + if axis == source.Axis { + continue + } + if !finite(angle) { + return fmt.Errorf("invalid PDF 3D section rotation") + } + sin, cos := math.Sincos(math.Remainder(angle, 360) * math.Pi / 180) + a, b := (axis+1)%3, (axis+2)%3 + for _, vector := range []*[3]float64{§ion.AxisU, §ion.AxisV} { + vector[a], vector[b] = cos*vector[a]-sin*vector[b], sin*vector[a]+cos*vector[b] + } + } + if source.IntersectionVisible { + color := source.IntersectionColor + section.Intersection = &color + } + options.Sections[i] = section + } + return nil +} diff --git a/ofdgo_pdf_annot.go b/ofdgo_pdf_annot.go index 68b2061..32eab81 100644 --- a/ofdgo_pdf_annot.go +++ b/ofdgo_pdf_annot.go @@ -29,6 +29,13 @@ import ( // 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, actions 外观区域的动作 // 返回: error 外观或转换错误 func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, actions ...Action) error { + return p.paintedAnnotation(ctx, page, annotation, nil, actions...) +} + +// paintedAnnotation 复用注解坐标、标志与元数据处理,允许替换静态绘制内容 +// 入参: ctx 取消上下文, page PDF页面, annotation PDF注解, paint 绘制回调,空值使用原外观, actions 外观区域的动作 +// 返回: error 外观或转换错误 +func (p *pdfImporter) paintedAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, paint func(*pdfImporter) error, actions ...Action) error { for _, key := range []pdfgo.Name{"AA", "OC", "A"} { if annotation.Subtype == "Widget" && (key == "A" || key == "AA") { continue @@ -74,29 +81,35 @@ func (p *pdfImporter) appearanceAnnotation(ctx context.Context, page *pdfgo.Page stamp.compositeCache = nil stamp.clipTexts = make(map[*pdfgo.TextClip][]TextObject) stamp.pageWidth, stamp.pageHeight = box.W, box.H - nodes, err := p.collectCompositeNodes(func(visitor pdfgo.Visitor) error { - if annotation.Subtype == "Widget" { - visitor.MarkedContent = func(mark pdfgo.MarkedContentMark) error { - if mark.Tag == "Tx" && len(mark.Properties) == 0 { + if paint != nil { + if err := paint(&stamp); err != nil { + return err + } + } else { + nodes, err := p.collectCompositeNodes(func(visitor pdfgo.Visitor) error { + if annotation.Subtype == "Widget" { + visitor.MarkedContent = func(mark pdfgo.MarkedContentMark) error { + if mark.Tag == "Tx" && len(mark.Properties) == 0 { + return nil + } + if mark.Operator == "EMC" { + return nil + } + if p.warning == nil { + return &pdfgo.UnsupportedError{Feature: "widget marked content " + string(mark.Tag)} + } + p.warning(pdfgo.Diagnostic{Message: "PDF widget marked content " + string(mark.Tag) + " not preserved"}) return nil } - if mark.Operator == "EMC" { - return nil - } - if p.warning == nil { - return &pdfgo.UnsupportedError{Feature: "widget marked content " + string(mark.Tag)} - } - p.warning(pdfgo.Diagnostic{Message: "PDF widget marked content " + string(mark.Tag) + " not preserved"}) - return nil } + return p.reader.WalkAnnotationAppearance(ctx, page, annotation, visitor) + }) + if err != nil { + return err + } + if err := stamp.compositeObjects(nodes); err != nil { + return err } - return p.reader.WalkAnnotationAppearance(ctx, page, annotation, visitor) - }) - if err != nil { - return err - } - if err := stamp.compositeObjects(nodes); err != nil { - return err } if flags&(2|32) == 0 && !invisible { p.compositeNodes = stamp.compositeNodes diff --git a/ofdgo_pdf_buffer.go b/ofdgo_pdf_buffer.go index 01fbe53..cee12f4 100644 --- a/ofdgo_pdf_buffer.go +++ b/ofdgo_pdf_buffer.go @@ -25,6 +25,15 @@ type pdfObjectBuffer struct { size int } +// objectValidation 在单次导入中共享不可变资源的校验结果,隔离局部编辑器 +// 返回: *editorValidation 当前编辑器的构建校验会话 +func (p *pdfImporter) objectValidation() *editorValidation { + if p.validation == nil || p.validation.Editor != p.editor { + p.validation = &editorValidation{Editor: p.editor} + } + return p.validation +} + // appendObject 追加本次导入对象,按需建立独立暂存缓冲 // 入参: object 独立导入对象 func (p *pdfImporter) appendObject(object GraphicObject) { diff --git a/ofdgo_pdf_clip_group.go b/ofdgo_pdf_clip_group.go index ac50a50..b15ff7e 100644 --- a/ofdgo_pdf_clip_group.go +++ b/ofdgo_pdf_clip_group.go @@ -51,7 +51,7 @@ func (p *pdfImporter) sharedClipObjects(objects []GraphicObject) ([]GraphicObjec for index := range members { *editorObjectClips(&members[index]) = nil } - id, err := p.editor.addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight) + id, err := p.objectValidation().addOwnedVector(p.ctx, members, p.pageWidth, p.pageHeight) if err != nil { return nil, err } diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index ed25cc0..c622e3d 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -33,6 +33,7 @@ type pdfCompositeNode struct { children []pdfCompositeNode textObject *pdfgo.TextObject glyph *pdfgo.TextMark + formCount int } // pdfCompositePixel 保存非预乘源空间分量及累计透明度 @@ -160,7 +161,15 @@ func (c *pdfCompositor) coverage(node pdfCompositeNode, stroke bool) (image.Imag if geometry.scene == nil || b.X >= c.box.X+c.box.W || b.Y >= c.box.Y+c.box.H || b.X+b.W <= c.box.X || b.Y+b.H <= c.box.Y { return nil, nil } - return c.importer.renderGroupScene(geometry.scene, c.box) + target, err := c.acquireCoverage() + if err != nil { + return nil, err + } + coverage, err := c.importer.renderGroupSceneInto(geometry.scene, c.box, target) + if err != nil { + c.releaseCoverage(target) + } + return coverage, err } // geometry 缓存图元或组的实际覆盖场景与像素对齐边界 @@ -431,6 +440,17 @@ func (p *pdfImporter) collectCompositeNodes(walk func(pdfgo.Visitor) error) ([]p nodes = append(nodes, pdfCompositeNode{group: &mark, children: children}) return nil } + v.Form = func(_ pdfgo.FormMark, walk func(pdfgo.Visitor) error) error { + children, err := collect(walk) + if err != nil { + return err + } + if len(children) != 0 { + children[0].formCount = len(children) + nodes = append(nodes, children...) + } + return nil + } err := walk(v) return nodes, err } @@ -503,7 +523,19 @@ func (p *pdfImporter) compositePage(space *pdfgo.ColorSpace, walk func(pdfgo.Vis // 返回: error 转换或合成错误 func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { space := p.compositeSpace - for i, node := range nodes { + for i := 0; i < len(nodes); i++ { + node := nodes[i] + if count := node.formCount; count >= 16 && count <= len(nodes)-i && !p.transferBackdrop { + packed, err := p.compositeForm(nodes[i : i+count]) + if err != nil { + return fmt.Errorf("convert form at graphic %d: %w", i+1, err) + } + if packed { + p.compositeNodes = append(p.compositeNodes, nodes[i:i+count]...) + i += count - 1 + continue + } + } direct, err := p.directCompositeNode(node, space) if err != nil { return fmt.Errorf("inspect graphic %d: %w", i+1, err) diff --git a/ofdgo_pdf_composite_draw.go b/ofdgo_pdf_composite_draw.go index b1606d9..b45ef68 100644 --- a/ofdgo_pdf_composite_draw.go +++ b/ofdgo_pdf_composite_draw.go @@ -109,6 +109,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf if err != nil || coverage == nil { return err } + defer c.releaseCoverage(coverage) style, _, _ := node.style() var transfer *pdfgo.TransferFunction imageShape := false @@ -198,7 +199,7 @@ func (c *pdfCompositor) drawPaint(node pdfCompositeNode, outline bool, paint pdf return err } for x := region.Min.X; x < region.Max.X; x++ { - _, _, _, a := coverage.At(x, y).RGBA() + a := imageAlphaAt(coverage, x, y) if a == 0 { continue } diff --git a/ofdgo_pdf_composite_scratch.go b/ofdgo_pdf_composite_scratch.go index ca16ca5..7a4bb2f 100644 --- a/ofdgo_pdf_composite_scratch.go +++ b/ofdgo_pdf_composite_scratch.go @@ -14,6 +14,8 @@ package ofdgo +import "image" + // pdfCompositeTileSize 限定单次合成块的像素边长 const pdfCompositeTileSize = 256 @@ -22,8 +24,51 @@ type pdfCompositeBuffers[T any] [8][]T // pdfCompositeScratch 复用像素及蒙版缓冲,不跨页持有活动内容 type pdfCompositeScratch struct { - pixels pdfCompositeBuffers[pdfCompositePixel] - masks pdfCompositeBuffers[float64] + pixels pdfCompositeBuffers[pdfCompositePixel] + masks pdfCompositeBuffers[float64] + rasters [8]*image.RGBA +} + +// acquireCoverage 借用当前块的独占画布,保留活动画布供嵌套合成读取 +// 返回: *image.RGBA 未清空画布,不支持缓冲绘制的后端返回nil, error 无效绘制区域 +func (c *pdfCompositor) acquireCoverage() (*image.RGBA, error) { + if _, ok := c.importer.editor.Backends().Raster.(RasterBufferBackend); !ok { + return nil, nil + } + page := RasterPage{Width: c.box.W, Height: c.box.H, DPI: c.importer.rasterDPI} + w, h, err := page.PixelSize() + if err != nil { + return nil, err + } + if c.scratch == nil { + c.scratch = &pdfCompositeScratch{} + } + rect := image.Rect(0, 0, w, h) + for i, stored := range c.scratch.rasters { + if stored != nil && stored.Rect == rect { + c.scratch.rasters[i] = nil + return stored, nil + } + } + return image.NewRGBA(rect), nil +} + +// releaseCoverage 归还当前调用不再读取的画布,限制单次合成保留的内存 +// 入参: coverage 当前调用的覆盖图像 +func (c *pdfCompositor) releaseCoverage(coverage image.Image) { + if _, ok := c.importer.editor.Backends().Raster.(RasterBufferBackend); !ok || c.scratch == nil { + return + } + buffer, ok := coverage.(*image.RGBA) + if !ok || buffer == nil || cap(buffer.Pix) > 4*pdfCompositeTileSize*pdfCompositeTileSize { + return + } + for i, stored := range c.scratch.rasters { + if stored == nil { + c.scratch.rasters[i] = buffer + return + } + } } // acquire 借用尚未初始化的独立缓冲,嵌套调用不复用活动缓冲 diff --git a/ofdgo_pdf_composite_shading.go b/ofdgo_pdf_composite_shading.go index c75ddcc..e984d55 100644 --- a/ofdgo_pdf_composite_shading.go +++ b/ofdgo_pdf_composite_shading.go @@ -80,6 +80,7 @@ func (c *pdfCompositor) shadingPattern(pattern *pdfgo.ShadingPattern, node pdfCo c.releasePixels(result) return nil, err } + defer c.releaseCoverage(coverage) step := 25.4 / c.importer.rasterDPI for i := range result { if i%pdfCompositeTileSize == 0 { @@ -90,7 +91,7 @@ func (c *pdfCompositor) shadingPattern(pattern *pdfgo.ShadingPattern, node pdfCo } shape := 0.0 if coverage != nil { - _, _, _, a := coverage.At(i%c.width, i/c.width).RGBA() + a := imageAlphaAt(coverage, i%c.width, i/c.width) shape = float64(a) / 65535 } if nodes[0].image != nil && shape != 0 { diff --git a/ofdgo_pdf_form.go b/ofdgo_pdf_form.go new file mode 100644 index 0000000..394959e --- /dev/null +++ b/ofdgo_pdf_form.go @@ -0,0 +1,142 @@ +// 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" + "crypto/sha256" + "encoding/xml" +) + +// pdfFormBufferLimit 限制表单编码暂存容量,不限制资源大小 +const pdfFormBufferLimit = 8 << 20 + +// pdfFormResource 保存生成内容及资源编号,复用前核对完整内容 +type pdfFormResource struct { + content []byte + id string +} + +// pdfFormCache 隔离编辑器的表单资源,复用编码缓冲而不共享缓存内容 +type pdfFormCache struct { + editor *Editor + entries renderCache[[32]byte, pdfFormResource] + buffer bytes.Buffer +} + +// encode 按原顺序编码对象分块,不为缓存检查整理连续对象数组 +// 入参: ctx 取消上下文, objects 表单对象, width 页面宽度, height 页面高度 +// 返回: []byte 下次编码或重置前有效的内容, error 编码或取消错误 +func (c *pdfFormCache) encode(ctx context.Context, objects *pdfObjectBuffer, width, height float64) ([]byte, error) { + c.buffer.Reset() + x := newOFDXML(&c.buffer) + x.ctx = ctx + x.root("Content", ofdAttrs{{Name: xml.Name{Local: "Width"}, Value: ofdNumber(width)}, + {Name: xml.Name{Local: "Height"}, Value: ofdNumber(height)}}) + for _, part := range objects.parts { + for _, object := range part { + x.object(object, false) + if x.err != nil { + return nil, x.err + } + } + } + x.end("Content") + if err := x.finish(); err != nil { + return nil, err + } + return c.buffer.Bytes(), nil +} + +// reset 清空临时内容,释放超限缓冲,不修改已登记资源 +func (c *pdfFormCache) reset() { + if c.buffer.Cap() > pdfFormBufferLimit { + c.buffer = bytes.Buffer{} + } else { + c.buffer.Reset() + } +} + +// compositeForm 将无需背景合成的普通表单保存为矢量资源,保留图元的实际坐标和裁剪 +// 入参: nodes 普通表单连续图元 +// 返回: bool 是否已保存为复合对象, error 检查或转换错误 +func (p *pdfImporter) compositeForm(nodes []pdfCompositeNode) (bool, error) { + if pdfCompositeHasTransfer(nodes) { + return false, nil + } + for _, node := range nodes { + if err := p.ctx.Err(); err != nil { + return false, err + } + direct, err := p.directCompositeNode(node, p.compositeSpace) + if err != nil || !direct { + return false, err + } + } + if err := p.flushPath(); err != nil { + return false, err + } + parent := p.objects + p.objects = nil + defer func() { p.objects = parent }() + visitor := p.visitor() + for _, node := range nodes { + if err := node.emit(visitor); err != nil { + return false, err + } + } + if err := p.flushPath(); err != nil { + return false, err + } + if err := p.ctx.Err(); err != nil { + return false, err + } + if p.objects.len() == 0 { + return true, nil + } + if p.formResources == nil || p.formResources.editor != p.editor { + p.formResources = &pdfFormCache{editor: p.editor, entries: renderCache[[32]byte, pdfFormResource]{limit: 32 << 20}} + } + cache := p.formResources + defer cache.reset() + content, err := cache.encode(p.ctx, p.objects, p.pageWidth, p.pageHeight) + if err != nil { + return false, err + } + key := sha256.Sum256(content) + resource, found := cache.entries.get(key) + if !found || !bytes.Equal(resource.content, content) { + objects, err := p.objects.data(p.ctx) + if err != nil { + return false, err + } + id, err := p.objectValidation().addOwnedVector(p.ctx, objects, p.pageWidth, p.pageHeight) + if err != nil { + return false, err + } + resource = pdfFormResource{id: id} + if len(content) <= cache.entries.limit-len(id)-128 { + resource.content = bytes.Clone(content) + cache.entries.put(key, resource, cap(resource.content)+len(id)+128) + } + } + p.objects = parent + p.appendObject(GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ + Boundary: pdfBoundary(Box{W: p.pageWidth, H: p.pageHeight}), ResourceID: resource.id, + }}) + parent = p.objects + return true, nil +} diff --git a/ofdgo_pdf_geometry.go b/ofdgo_pdf_geometry.go index b2b28a6..873cafe 100644 --- a/ofdgo_pdf_geometry.go +++ b/ofdgo_pdf_geometry.go @@ -158,9 +158,22 @@ func (p *pdfImporter) compileCoverage(node pdfCompositeNode, stroke bool) (*Rast } evenOdd = false } - path, err = clipGeometry(geometry, path, &clip) - if err != nil { - return nil, Box{}, err + var clipped GeometryPath + var linear bool + var clipErr error + if rectangle { + clipped, linear, clipErr = clipLinearFillGeometry(path, clip) + } + if clipErr != nil { + return nil, Box{}, clipErr + } + if linear { + path = clipped + } else { + path, err = clipGeometry(geometry, path, &clip) + if err != nil { + return nil, Box{}, err + } } } if err := compiler.command(path, Paint{Color: color.RGBA{255, 255, 255, 255}}, evenOdd, nil, IdentityMatrix, nil); err != nil { diff --git a/ofdgo_pdf_group.go b/ofdgo_pdf_group.go index 58bc6c9..a06df13 100644 --- a/ofdgo_pdf_group.go +++ b/ofdgo_pdf_group.go @@ -435,6 +435,13 @@ func (p *pdfImporter) groupSceneBounds(scene *RasterPage) (*RasterPage, Box, err // 入参: source 原始场景, box 页面裁取区域 // 返回: image.Image 透明图像, error 后端渲染错误 func (p *pdfImporter) renderGroupScene(source *RasterPage, box Box) (image.Image, error) { + return p.renderGroupSceneInto(source, box, nil) +} + +// renderGroupSceneInto 在指定区域绘制场景,按所选后端能力复用独占目标 +// 入参: source 原始场景, box 页面裁取区域, target 可选目标缓冲 +// 返回: image.Image 透明图像, error 后端渲染错误 +func (p *pdfImporter) renderGroupSceneInto(source *RasterPage, box Box, target *image.RGBA) (image.Image, error) { scene := *source scene.DPI = p.rasterDPI scene.Commands = append([]RasterCommand(nil), source.Commands...) @@ -457,6 +464,14 @@ func (p *pdfImporter) renderGroupScene(source *RasterPage, box Box) (image.Image if backend == nil { return nil, fmt.Errorf("PDF local compositing: %w", ErrBackendUnavailable) } + if target != nil { + if buffered, ok := backend.(RasterBufferBackend); ok { + if err := buffered.RenderInto(&scene, target); err != nil { + return nil, err + } + return target, nil + } + } return backend.Render(&scene) } diff --git a/ofdgo_pdf_media.go b/ofdgo_pdf_media.go index f2c3e37..1855116 100644 --- a/ofdgo_pdf_media.go +++ b/ofdgo_pdf_media.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/binary" + "errors" "fmt" "math" "net/url" @@ -134,33 +135,19 @@ func (p *pdfImporter) movieAnnotation(ctx context.Context, page *pdfgo.Page, ann // 入参: ctx 取消上下文, page PDF页面, annotation 交互注解, strict 严格检查开关 // 返回: error 外观、资源或不可等价转换错误 func (p *pdfImporter) interactiveAnnotation(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, strict bool) error { + if err := ctx.Err(); err != nil { + return err + } if strict { return &pdfgo.UnsupportedError{Feature: "interactive " + string(annotation.Subtype) + " conversion"} } var actions []Action if annotation.Subtype == "3D" { - model, err := p.reader.ReadThreeD(annotation) - if err != nil { - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF 3D metadata unavailable; static appearance retained: " + err.Error()}) - return p.appearanceAnnotation(ctx, page, annotation) - } - data, err := model.Stream.Decode() - if err != nil { - return err - } - id, err := p.editor.AddAttachment(fmt.Sprintf("Model_%d.%s", p.page+1, strings.ToLower(string(model.Format))), data) - if err != nil { - return err - } - actions = append(actions, Action{Event: "CLICK", GotoA: &GotoA{AttachID: id}}) + return p.threeDAnnotation(ctx, page, annotation) } else { media, err := p.reader.ReadRichMedia(ctx, annotation) if err != nil { - if ctx.Err() != nil { - return ctx.Err() - } - p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: "PDF rich media metadata unavailable; static appearance retained: " + err.Error()}) - return p.appearanceAnnotation(ctx, page, annotation) + return p.interactiveAppearanceFallback(ctx, page, annotation, "metadata", err) } config := media.Configurations[media.ActiveConfiguration] var playing *pdfgo.FileSpecification @@ -245,6 +232,23 @@ func (p *pdfImporter) interactiveAnnotation(ctx context.Context, page *pdfgo.Pag return nil } +// interactiveAppearanceFallback 保留交互注解的有效正常外观,取消时不继续转换 +// 入参: ctx 取消上下文, page PDF页面, annotation 交互注解, kind 失败资源类型, cause 原始错误 +// 返回: error 取消或外观转换错误 +func (p *pdfImporter) interactiveAppearanceFallback(ctx context.Context, page *pdfgo.Page, annotation pdfgo.Annotation, kind string, cause error) error { + if err := ctx.Err(); err != nil { + return err + } + if errors.Is(cause, context.Canceled) || errors.Is(cause, context.DeadlineExceeded) { + return cause + } + if err := p.appearanceAnnotation(ctx, page, annotation); err != nil { + return err + } + p.warning(pdfgo.Diagnostic{Page: p.page + 1, Message: fmt.Sprintf("PDF %s %s unavailable; normal appearance retained: %v", annotation.Subtype, kind, cause)}) + return nil +} + // mediaData 读取媒体或附件,宽松模式缺少外部读取器时报告资源不可用 // 入参: ctx 取消上下文, file PDF文件说明 // 返回: []byte 原始文件, bool 是否可用, error 解码、外部读取或严格模式错误 diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index 6b53fe3..c15cf36 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -252,10 +252,7 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { if err := p.flushPath(); err != nil { return err } - visitor := pdfgo.Visitor{Path: p.path, Image: p.image, Warning: p.warning, Reference: p.referencePage} - visitor.Group = func(group pdfgo.GroupMark, walk func(pdfgo.Visitor) error) error { - return p.group(group, walk, visitor) - } + visitor := p.visitor() for index := range mark.Glyphs { if err := p.reader.WalkType3Glyph(p.ctx, mark, index, visitor); err != nil { return err diff --git a/ofdgo_renderer_cache_canvas.go b/ofdgo_renderer_cache_canvas.go new file mode 100644 index 0000000..023878f --- /dev/null +++ b/ofdgo_renderer_cache_canvas.go @@ -0,0 +1,70 @@ +// 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 ( + "crypto/sha256" + "encoding/binary" + "math" + + "github.com/tdewolff/canvas" +) + +// canvasGeometryDigest 按完整坐标、路径顺序及几何容差计算摘要 +// 入参: paths 非空路径序列 +// 返回: [32]byte 几何摘要 +func canvasGeometryDigest(paths ...*canvas.Path) [32]byte { + hash := sha256.New() + var data [512]byte + binary.LittleEndian.PutUint64(data[:8], math.Float64bits(canvas.Tolerance)) + binary.LittleEndian.PutUint64(data[8:16], math.Float64bits(canvas.Epsilon)) + binary.LittleEndian.PutUint64(data[16:24], math.Float64bits(canvas.BentleyOttmannEpsilon)) + hash.Write(data[:24]) + for _, path := range paths { + values := path.Data() + binary.LittleEndian.PutUint64(data[:8], uint64(len(values))) + hash.Write(data[:8]) + for len(values) > 0 { + count := min(len(values), len(data)/8) + for i, value := range values[:count] { + binary.LittleEndian.PutUint64(data[i*8:], math.Float64bits(value)) + } + hash.Write(data[:count*8]) + values = values[count:] + } + } + var key [32]byte + hash.Sum(key[:0]) + return key +} + +// settleCanvasPath 复用复杂路径的填充规范化结果,返回独立副本 +// 入参: path 页面坐标路径, rule 填充规则 +// 返回: *canvas.Path 规范化填充轮廓 +func (r *Renderer) settleCanvasPath(path *canvas.Path, rule canvas.FillRule) *canvas.Path { + if len(path.Data()) < 256 { + return path.Settle(rule) + } + key := canvasFillKey{digest: canvasGeometryDigest(path), rule: rule} + cache := r.canvasSettledPaths() + if result, ok := cache.get(key); ok { + return result.Copy() + } + result := path.Settle(rule) + if cost := len(result.Data())*8 + 256; cost <= cache.limit { + cache.put(key, result.Copy(), cost) + } + return result +} diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index d9a691b..51015ce 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -55,6 +55,30 @@ func (b CanvasBackend) Render(page *RasterPage) (image.Image, error) { return nil, err } img := image.NewRGBA(image.Rect(0, 0, w, h)) + return b.renderImage(page, img) +} + +// RenderInto 在调用方独占的紧密RGBA缓冲中重新绘制,不修改页面或保留缓冲 +// 入参: page 只读页面, target 原点为零且尺寸匹配的目标,不与输入图像共享数据 +// 返回: error 参数或绘制错误,绘制失败时目标可能包含部分结果 +func (b CanvasBackend) RenderInto(page *RasterPage, target *image.RGBA) error { + w, h, err := page.PixelSize() + if err != nil { + return err + } + if target == nil || target.Rect != image.Rect(0, 0, w, h) || target.Stride != 4*w || len(target.Pix) < 4*w*h { + return fmt.Errorf("invalid canvas target buffer") + } + clear(target.Pix[:4*w*h]) + _, err = b.renderImage(page, target) + return err +} + +// renderImage 将页面绘制到已清空且尺寸匹配的目标 +// 入参: page 只读页面, img 独占目标 +// 返回: image.Image 目标图像, error 绘制错误 +func (b CanvasBackend) renderImage(page *RasterPage, img *image.RGBA) (image.Image, error) { + h := img.Rect.Dy() space := b.ColorSpace if space == nil { space = canvas.DefaultColorSpace diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 8c9f6dd..6f843fa 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -15,10 +15,7 @@ package ofdgo import ( - "crypto/sha256" - "encoding/binary" "image" - "math" "slices" "github.com/tdewolff/canvas" @@ -195,27 +192,7 @@ func (r *Renderer) intersectCachedClipPath(parent, current *canvas.Path) *canvas if parent == nil || current == nil || len(parent.Data())+len(current.Data()) < 256 { return intersectClipPath(parent, current) } - hash := sha256.New() - var data [512]byte - binary.LittleEndian.PutUint64(data[:8], math.Float64bits(canvas.Tolerance)) - binary.LittleEndian.PutUint64(data[8:16], math.Float64bits(canvas.Epsilon)) - binary.LittleEndian.PutUint64(data[16:24], math.Float64bits(canvas.BentleyOttmannEpsilon)) - hash.Write(data[:24]) - for _, path := range []*canvas.Path{parent, current} { - values := path.Data() - binary.LittleEndian.PutUint64(data[:8], uint64(len(values))) - hash.Write(data[:8]) - for len(values) > 0 { - count := min(len(values), len(data)/8) - for i, value := range values[:count] { - binary.LittleEndian.PutUint64(data[i*8:], math.Float64bits(value)) - } - hash.Write(data[:count*8]) - values = values[count:] - } - } - var key [32]byte - hash.Sum(key[:0]) + key := canvasGeometryDigest(parent, current) cache := r.canvasClipIntersections() if path, ok := cache.get(key); ok { return path.Copy() @@ -310,6 +287,8 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { if path == nil || clip == nil { return path } + path = reduceRepeatedNonZeroPath(path) + clip = reduceRepeatedNonZeroPath(clip) if slices.Equal(path.Data(), clip.Data()) { return path } @@ -340,6 +319,36 @@ func applyClipPath(path, clip *canvas.Path) *canvas.Path { return path.And(clip) } +// reduceRepeatedNonZeroPath 将完全相同的闭合轮廓约为一份,保持非零填充区域 +// 入参: path 非零填充路径 +// 返回: *canvas.Path 等价填充路径 +func reduceRepeatedNonZeroPath(path *canvas.Path) *canvas.Path { + if path == nil { + return path + } + data := path.Data() + if len(data) < 8 || data[0] != canvas.MoveToCmd { + return path + } + end := 0 + scanner := path.Scanner() + for scanner.Scan() { + if scanner.Cmd() == canvas.MoveToCmd && end != 0 { + break + } + end += len(scanner.Values()) + 2 + } + if end == len(data) || data[end-1] != canvas.CloseCmd || len(data)%end != 0 { + return path + } + for i := end; i < len(data); i += end { + if !slices.Equal(data[:end], data[i:i+end]) { + return path + } + } + return canvas.NewPathFromData(slices.Clone(data[:end])) +} + // applyFillClipPath 为多段描边直接填充裁剪,不将相消轮廓用于几何测量 // 入参: path 非零填充描边, clip 裁剪路径 // 返回: *canvas.Path 绘制轮廓 diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 71d464e..deebd7f 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -400,6 +400,25 @@ func geometryFromCanvasPath(path *canvas.Path) *GeometryPath { return &result } +// clipLinearFillGeometry 裁剪直线填充轮廓,保留原填充规则,不用于区域布尔运算 +// 入参: path 闭合绘制轮廓, clip 凸裁剪窗口 +// 返回: GeometryPath 填充轮廓, bool 是否适用, error 路径错误 +func clipLinearFillGeometry(path, clip GeometryPath) (GeometryPath, bool, error) { + a, err := geometryToCanvasPath(&path) + if err != nil { + return nil, false, err + } + b, err := geometryToCanvasPath(&clip) + if err != nil { + return nil, false, err + } + result, ok := intersectLinearCanvasContours(a, b) + if !ok { + return nil, false, nil + } + return *geometryFromCanvasPath(result), true, nil +} + // actionCanvasMatrix 转换页面坐标矩阵,不翻转Y轴 // 入参: matrix 页面变换 // 返回: canvas.Matrix 路径变换 diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index d432837..0351b49 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -253,13 +253,16 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 } fp := paths.Merge() fillRule := canvas.NonZero + if fillClip != nil && !fp.Bounds().Overlaps(fillClip.Bounds()) { + fp = &canvas.Path{} + } if obj.Rule == "Even-Odd" { fillRule = canvas.EvenOdd if bounds, ok := rectangularPath(fillClip); ok && bounds.Contains(fp.Bounds()) { fillClip = nil } if fillClip != nil || style.fillPattern != nil { - fp = fp.Settle(fillRule) + fp = r.settleCanvasPath(fp, fillRule) } } if fillClip != nil { diff --git a/ofdgo_renderer_state_canvas.go b/ofdgo_renderer_state_canvas.go index 845dc0b..ab62bc9 100644 --- a/ofdgo_renderer_state_canvas.go +++ b/ofdgo_renderer_state_canvas.go @@ -33,6 +33,15 @@ type canvasClipCacheKey struct{} // canvasPathCacheKey 区分不依赖文档资源的共享局部路径缓存 type canvasPathCacheKey struct{} +// canvasFillCacheKey 区分按填充规则规范化的共享路径缓存 +type canvasFillCacheKey struct{} + +// canvasFillKey 保存完整几何摘要及填充规则 +type canvasFillKey struct { + digest [32]byte + rule canvas.FillRule +} + // canvasPathKey 按完整路径文本与几何容差区分解析结果 type canvasPathKey struct { data string @@ -78,6 +87,21 @@ func (r *Renderer) canvasParsedPaths() *renderCache[canvasPathKey, *canvas.Path] return cache } +// canvasSettledPaths 获取共享填充轮廓缓存,限制结果保留量 +// 返回: *renderCache[canvasFillKey, *canvas.Path] 填充轮廓缓存 +func (r *Renderer) canvasSettledPaths() *renderCache[canvasFillKey, *canvas.Path] { + key := canvasFillCacheKey{} + if state, ok := r.sharedBackendStates[key]; ok { + return state.(*renderCache[canvasFillKey, *canvas.Path]) + } + if r.sharedBackendStates == nil { + r.sharedBackendStates = make(map[any]any) + } + cache := &renderCache[canvasFillKey, *canvas.Path]{limit: 16 << 20} + r.sharedBackendStates[key] = cache + return cache +} + // canvasFontFamilies 获取子渲染器共享的只读字体族缓存,限制解析数据保留量 // 返回: *renderCache[canvasFontKey, *canvas.FontFamily] 字体族缓存 func (r *Renderer) canvasFontFamilies() *renderCache[canvasFontKey, *canvas.FontFamily] { diff --git a/ofdgo_u3d.go b/ofdgo_u3d.go new file mode 100644 index 0000000..a7a3826 --- /dev/null +++ b/ofdgo_u3d.go @@ -0,0 +1,589 @@ +// 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" + "encoding/binary" + "fmt" + "io" + "math" +) + +// u3dDefaultBytes 限制默认解码数据规模,不包括调用方已有输入及运行时开销 +const u3dDefaultBytes = 256 << 20 + +// U3DOptions 控制U3D解码规模,MaxDecodedBytes为0时采用256MiB上限 +type U3DOptions struct { + MaxDecodedBytes int64 +} + +// U3DModel 保存独立于PDF的静态U3D场景,不执行动作或读取外部文件 +type U3DModel struct { + Major, Minor int16 + DefinedUnits bool + Units float64 + Meshes []U3DMesh + Nodes []U3DNode + Materials []U3DMaterial + Shaders []U3DShader + Lights []U3DLight + Views []U3DViewResource + Metadata []U3DMetadata +} + +// U3DMetadata 保留块所属名称与原始元数据,数据不与输入共享存储 +type U3DMetadata struct { + Block uint32 + Name string + Data []byte +} + +// U3DNode 保存组、模型、光源或视图节点,父节点矩阵按列存储 +type U3DNode struct { + Name, Kind, Resource string + Parents []U3DParent + Visibility uint32 + Shaders [][]string + View *U3DView +} + +// U3DParent 保存父节点名称及局部到父节点的变换,空名称引用默认节点 +type U3DParent struct { + Name string + Transform [16]float32 +} + +// U3DMaterial 保存材质参数,Attributes各位决定相应参数是否生效 +type U3DMaterial struct { + Name string + Attributes uint32 + Ambient, Diffuse, Specular, Emissive [3]float32 + Reflectivity, Opacity float32 +} + +// U3DShader 保存无纹理着色器参数,不提前应用混合、透明测试或光照 +type U3DShader struct { + Name, Material string + Attributes, AlphaFunction, Blend uint32 + RenderPass uint32 + AlphaReference float32 +} + +// U3DLight 保存环境、方向、点或聚光资源,Kind依次为0至3 +type U3DLight struct { + Name string + Attributes uint32 + Kind byte + Color, Attenuation [3]float32 + SpotAngle, Intensity float32 +} + +// u3dBlock 保存校验后的块边界,数据切片仅在解码期间引用输入 +type u3dBlock struct { + kind uint32 + data, meta []byte +} + +// u3dDecoder 保存单次解码预算及场景索引,不跨调用缓存输入 +type u3dDecoder struct { + ctx context.Context + model U3DModel + remaining uint64 + plain bool + meshes map[string]*u3dMeshDeclaration + meshOrder []string + nodes map[string]int + palettes map[uint32]map[string]bool +} + +// DecodeU3D 按ECMA-363解码静态基础网格、组、材质、着色器和光源 +// 暂不支持渐进网格、纹理和动画,遇到未支持内容返回错误而非部分场景 +// 入参: ctx 取消上下文, data U3D数据, options 解码限制 +// 返回: *U3DModel 独立场景数据, error 格式、能力、预算或取消错误 +func DecodeU3D(ctx context.Context, data []byte, options U3DOptions) (*U3DModel, error) { + if ctx == nil || options.MaxDecodedBytes < 0 { + return nil, fmt.Errorf("invalid U3D decode options") + } + if err := ctx.Err(); err != nil { + return nil, err + } + limit := options.MaxDecodedBytes + if limit == 0 { + limit = u3dDefaultBytes + } + d := u3dDecoder{ctx: ctx, remaining: uint64(limit), meshes: make(map[string]*u3dMeshDeclaration), nodes: make(map[string]int), palettes: make(map[uint32]map[string]bool)} + header, next, err := u3dReadBlock(data, 0) + if err != nil { + return nil, err + } + if header.kind != 0x00443355 { + return nil, fmt.Errorf("invalid U3D file header") + } + r := u3dValues{data: header.data} + version, profile := r.u32(), r.u32() + d.model.Major, d.model.Minor = int16(version), int16(version>>16) + if d.model.Major > 0 || profile & ^uint32(14) != 0 { + return nil, fmt.Errorf("unsupported U3D version or profile") + } + declarationEnd := uint64(r.u32()) + fileSize := uint64(r.u32()) + fileSize |= uint64(r.u32()) << 32 + if r.u32() != 106 { + return nil, fmt.Errorf("unsupported U3D character encoding") + } + d.plain = profile&4 != 0 + d.model.DefinedUnits, d.model.Units = profile&8 != 0, 1 + if d.model.DefinedUnits { + unitBytes := r.take(8) + if r.err == nil { + d.model.Units = math.Float64frombits(binary.LittleEndian.Uint64(unitBytes)) + if !finite(d.model.Units) || d.model.Units <= 0 { + return nil, fmt.Errorf("invalid U3D units") + } + } + } + if err := r.done(); err != nil { + return nil, err + } + if fileSize != uint64(len(data)) || declarationEnd < uint64(next) || declarationEnd > fileSize || declarationEnd%4 != 0 { + return nil, fmt.Errorf("invalid U3D file size") + } + if err := d.metadata(header, ""); err != nil { + return nil, err + } + priority := uint32(0) + for offset := next; offset < len(data); { + if err := ctx.Err(); err != nil { + return nil, err + } + block, end, err := u3dReadBlock(data, offset) + if err != nil { + return nil, fmt.Errorf("U3D block at %d: %w", offset, err) + } + continuation := block.kind == 0xffffff15 || block.kind == 0xffffff3b || block.kind == 0xffffff3c + if uint64(offset) < declarationEnd && (uint64(end) > declarationEnd || continuation) || uint64(offset) >= declarationEnd && !continuation { + return nil, fmt.Errorf("invalid U3D declaration boundary at %d", offset) + } + if block.kind == 0xffffff15 { + r := u3dValues{data: block.data} + value := r.u32() + if err := r.done(); err != nil { + return nil, err + } + if value == 0 || value < priority || value > 0x7fffffff { + return nil, fmt.Errorf("invalid U3D block priority") + } + priority = value + err = d.metadata(block, "") + } else if block.kind == 0xffffff14 { + err = d.chain(block) + } else { + _, err = d.block(block, "", -1, 0) + } + if err != nil { + return nil, fmt.Errorf("U3D block %#x at %d: %w", block.kind, offset, err) + } + offset = end + } + for _, name := range d.meshOrder { + mesh := d.meshes[name] + if !mesh.decoded && mesh.counts[1] != 0 { + return nil, fmt.Errorf("missing U3D base mesh %q", name) + } + d.model.Meshes = append(d.model.Meshes, mesh.mesh) + } + if err := d.checkParents(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + return &d.model, nil +} + +// u3dReadBlock 按64位长度计算校验块数据与零填充,避免长度溢出 +// 入参: data 块序列, offset 块起点 +// 返回: u3dBlock 块, int 下一块起点, error 边界或填充错误 +func u3dReadBlock(data []byte, offset int) (u3dBlock, int, error) { + if offset < 0 || offset > len(data) || len(data)-offset < 12 { + return u3dBlock{}, 0, io.ErrUnexpectedEOF + } + header := data[offset : offset+12] + size, meta := uint64(binary.LittleEndian.Uint32(header[4:])), uint64(binary.LittleEndian.Uint32(header[8:])) + start := uint64(offset) + 12 + metaStart := start + ((size + 3) &^ 3) + end := metaStart + ((meta + 3) &^ 3) + if end > uint64(len(data)) { + return u3dBlock{}, 0, io.ErrUnexpectedEOF + } + for _, padding := range [][]byte{data[start+size : metaStart], data[metaStart+meta : end]} { + for _, value := range padding { + if value != 0 { + return u3dBlock{}, 0, fmt.Errorf("invalid U3D padding") + } + } + } + return u3dBlock{binary.LittleEndian.Uint32(header), data[start : start+size], data[metaStart : metaStart+meta]}, int(end), nil +} + +// reserve 在分配解码数组前检查总预算及取消状态 +// 入参: count 元素数量, size 元素规模上界 +// 返回: error 预算或取消错误 +func (d *u3dDecoder) reserve(count, size uint64) error { + if err := d.ctx.Err(); err != nil { + return err + } + if size == 0 || count > d.remaining/size || count > uint64(^uint(0)>>1)/size { + return fmt.Errorf("U3D decoded data exceeds limit") + } + d.remaining -= count * size + return nil +} + +// metadata 校验元数据结构并保留原始数据,不解释或执行其中的动作 +// 入参: block 数据块, name 所属资源名 +// 返回: error 结构、预算或取消错误 +func (d *u3dDecoder) metadata(block u3dBlock, name string) error { + if len(block.meta) == 0 { + return nil + } + if err := d.reserve(uint64(len(block.meta))+128, 1); err != nil { + return err + } + r := u3dValues{data: block.meta} + count := r.u32() + if uint64(count) > uint64(len(block.meta))/8 { + return fmt.Errorf("invalid U3D metadata count") + } + for range count { + if err := d.ctx.Err(); err != nil { + return err + } + flags := r.u32() + r.text() + if flags&1 != 0 { + size := r.u32() + if uint64(size) > uint64(len(r.data)-r.pos) { + return io.ErrUnexpectedEOF + } + r.take(int(size)) + } else { + r.text() + } + if r.err != nil { + return r.err + } + } + if err := r.done(); err != nil { + return err + } + d.model.Metadata = append(d.model.Metadata, U3DMetadata{block.kind, name, bytes.Clone(block.meta)}) + return nil +} + +// chain 解析单层修饰链并校验名称、链类型与修饰顺序 +// 入参: block 修饰链块 +// 返回: error 结构、能力或资源错误 +func (d *u3dDecoder) chain(block u3dBlock) error { + if err := d.reserve(256, 1); err != nil { + return err + } + r := u3dValues{data: block.data} + name, kind, flags := r.text(), r.u32(), r.u32() + if kind > 2 || flags & ^uint32(3) != 0 { + return fmt.Errorf("invalid U3D modifier chain") + } + if flags&1 != 0 { + for range 4 { + r.f32() + } + } + if flags&2 != 0 { + for range 6 { + r.f32() + } + } + for r.pos%4 != 0 && r.err == nil { + if r.u8() != 0 { + return fmt.Errorf("invalid U3D chain padding") + } + } + count := r.u32() + if r.err != nil { + return r.err + } + if uint64(count) > uint64(len(r.data)-r.pos)/12 { + return fmt.Errorf("invalid U3D modifier count") + } + for index := uint32(0); index < count; index++ { + if err := d.ctx.Err(); err != nil { + return err + } + nested, next, err := u3dReadBlock(r.data, r.pos) + if err != nil { + return err + } + actual, err := d.block(nested, name, int(kind), index) + if err != nil { + return err + } + if actual != name { + return fmt.Errorf("U3D modifier name mismatch") + } + r.pos = next + } + if err := r.done(); err != nil { + return err + } + return d.metadata(block, name) +} + +// block 解析支持的静态场景块,不丢弃未实现的修饰器或几何 +// 入参: block 数据块, owner 所属链名, chain 链类别,负值表示独立块, index 修饰位置 +// 返回: string 资源名, error 格式、能力或资源错误 +func (d *u3dDecoder) block(block u3dBlock, owner string, chain int, index uint32) (string, error) { + if err := d.ctx.Err(); err != nil { + return "", err + } + if err := d.reserve(uint64(len(block.data))+256, 1); err != nil { + return "", err + } + r := u3dValues{data: block.data} + name := r.text() + var err error + switch block.kind { + case 0xffffff21, 0xffffff22, 0xffffff23, 0xffffff24: + if chain >= 0 && (chain != 0 || index != 0) { + return name, fmt.Errorf("invalid U3D node chain") + } + err = d.node(&r, block.kind, name) + case 0xffffff31: + if chain >= 0 && (chain != 1 || index != 0) { + return name, fmt.Errorf("invalid U3D mesh chain") + } + err = d.meshDeclaration(&r, name) + case 0xffffff3b: + if chain >= 0 { + return name, fmt.Errorf("invalid U3D mesh continuation location") + } + err = d.baseMesh(&r, name) + 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: + if chain >= 0 { + return name, fmt.Errorf("invalid U3D palette resource location") + } + err = d.resource(&r, block.kind, name) + default: + return name, fmt.Errorf("unsupported U3D block %#x", block.kind) + } + if err != nil { + return name, err + } + if err := r.done(); err != nil { + return name, err + } + return name, d.metadata(block, name) +} + +// node 读取节点与父变换,允许引用后续声明的父节点 +// 入参: r 字段读取器, kind 节点类型, name 节点名 +// 返回: error 结构或预算错误 +func (d *u3dDecoder) node(r *u3dValues, kind uint32, name string) error { + if _, exists := d.nodes[name]; exists { + return fmt.Errorf("duplicate U3D node %q", name) + } + node := U3DNode{Name: name, Kind: "Group"} + count := r.u32() + if uint64(count) > uint64(len(r.data)-r.pos)/66 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 96); err != nil { + return err + } + node.Parents = make([]U3DParent, int(count)) + for index := range node.Parents { + if index%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + node.Parents[index].Name = r.text() + for element := range node.Parents[index].Transform { + node.Parents[index].Transform[element] = r.f32() + } + } + if kind == 0xffffff22 { + node.Kind, node.Resource, node.Visibility = "Model", r.text(), r.u32() + if node.Visibility > 3 { + return fmt.Errorf("invalid U3D model visibility") + } + } else if kind == 0xffffff23 { + node.Kind, node.Resource = "Light", r.text() + } else if kind == 0xffffff24 { + node.Kind, node.Resource = "View", r.text() + var err error + node.View, err = d.view(r) + if err != nil { + return err + } + } + d.nodes[name] = len(d.model.Nodes) + d.model.Nodes = append(d.model.Nodes, node) + return r.err +} + +// shading 读取网格着色列表,保留列表内各着色器的先后次序 +// 入参: r 字段读取器, name 目标节点名 +// 返回: error 结构、能力或预算错误 +func (d *u3dDecoder) shading(r *u3dValues, name string) error { + index, exists := d.nodes[name] + if !exists || d.model.Nodes[index].Kind != "Model" { + return fmt.Errorf("missing U3D shading target") + } + if r.u32() != 1 { + return fmt.Errorf("unsupported U3D non-mesh shading modifier") + } + count := r.u32() + if uint64(count) > uint64(len(r.data)-r.pos)/4 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 24); err != nil { + return err + } + lists := make([][]string, int(count)) + for index := range lists { + n := r.u32() + if uint64(n) > uint64(len(r.data)-r.pos)/2 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(n), 16); err != nil { + return err + } + lists[index] = make([]string, int(n)) + for j := range lists[index] { + if j%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + lists[index][j] = r.text() + } + } + d.model.Nodes[index].Shaders = lists + return r.err +} + +// resource 读取独立材质、着色器、光源和视图资源 +// 入参: r 字段读取器, kind 资源类型, name 资源名 +// 返回: error 结构或能力错误 +func (d *u3dDecoder) resource(r *u3dValues, kind uint32, name string) error { + palette := d.palettes[kind] + if palette == nil { + palette = make(map[string]bool) + d.palettes[kind] = palette + } + if palette[name] { + return fmt.Errorf("duplicate U3D resource %q", name) + } + palette[name] = true + switch kind { + case 0xffffff52: + return d.viewResource(r, name) + case 0xffffff54: + material := U3DMaterial{Name: name, Attributes: r.u32()} + if material.Attributes & ^uint32(63) != 0 { + return fmt.Errorf("invalid U3D material attributes") + } + for _, color := range []*[3]float32{&material.Ambient, &material.Diffuse, &material.Specular, &material.Emissive} { + for index := range color { + color[index] = r.f32() + } + } + material.Reflectivity, material.Opacity = r.f32(), r.f32() + d.model.Materials = append(d.model.Materials, material) + case 0xffffff53: + shader := U3DShader{Name: name, Attributes: r.u32(), AlphaReference: r.f32(), AlphaFunction: r.u32(), Blend: r.u32(), RenderPass: r.u32()} + channels, alpha := r.u32(), r.u32() + shader.Material = r.text() + 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") + } + d.model.Shaders = append(d.model.Shaders, shader) + case 0xffffff51: + light := U3DLight{Name: name, Attributes: r.u32(), Kind: r.u8()} + if light.Attributes & ^uint32(7) != 0 || light.Kind > 3 { + return fmt.Errorf("invalid U3D light parameters") + } + for index := range light.Color { + light.Color[index] = r.f32() + } + if r.f32() != 1 { + return fmt.Errorf("invalid U3D light reserved parameter") + } + for index := range light.Attenuation { + light.Attenuation[index] = r.f32() + } + light.SpotAngle, light.Intensity = r.f32(), r.f32() + d.model.Lights = append(d.model.Lights, light) + } + return r.err +} + +// checkParents 迭代检查场景父子环路,不递归展开多父节点的实例组合 +// 返回: error 环路或取消错误 +func (d *u3dDecoder) checkParents() error { + state := make([]byte, len(d.model.Nodes)) + type frame struct{ node, parent int } + var stack []frame + for index := range d.model.Nodes { + if state[index] != 0 { + continue + } + stack = append(stack[:0], frame{node: index}) + state[index] = 1 + for len(stack) != 0 { + if err := d.ctx.Err(); err != nil { + return err + } + current := &stack[len(stack)-1] + parents := d.model.Nodes[current.node].Parents + if current.parent == len(parents) { + state[current.node] = 2 + stack = stack[:len(stack)-1] + continue + } + parent, exists := d.nodes[parents[current.parent].Name] + current.parent++ + if !exists || state[parent] == 2 { + continue + } + if state[parent] == 1 { + return fmt.Errorf("cyclic U3D node parents") + } + state[parent] = 1 + stack = append(stack, frame{node: parent}) + } + } + return nil +} diff --git a/ofdgo_u3d_bits.go b/ofdgo_u3d_bits.go new file mode 100644 index 0000000..e7454e1 --- /dev/null +++ b/ofdgo_u3d_bits.go @@ -0,0 +1,301 @@ +// 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 ( + "encoding/binary" + "fmt" + "io" + "math" + "math/bits" + "unicode/utf8" +) + +// u3dValues 读取U3D未压缩字段,不允许越过当前块 +type u3dValues struct { + data []byte + pos int + err error +} + +// u3dBits 按ECMA-363第10章逆解16位算术区间,字节内按低位优先读取 +type u3dBits struct { + data []byte + pos uint64 + low, high, code uint32 + err error +} + +// u3dHistogram 保存一个动态压缩上下文的频次及前缀和 +type u3dHistogram struct { + counts []uint32 + tree []uint32 + total uint32 +} + +// take 读取定长字段,失败后不继续推进位置 +// 入参: size 字节数 +// 返回: []byte 字段数据,失败时为空 +func (r *u3dValues) take(size int) []byte { + if r.err != nil { + return nil + } + if size < 0 || size > len(r.data)-r.pos { + r.err = io.ErrUnexpectedEOF + return nil + } + value := r.data[r.pos : r.pos+size] + r.pos += size + return value +} + +// u8 读取一个未压缩字节 +// 返回: byte 数值 +func (r *u3dValues) u8() byte { + value := r.take(1) + if value == nil { + return 0 + } + return value[0] +} + +// u32 读取小端32位整数 +// 返回: uint32 数值 +func (r *u3dValues) u32() uint32 { + value := r.take(4) + if value == nil { + return 0 + } + return binary.LittleEndian.Uint32(value) +} + +// f32 读取有限单精度数值 +// 返回: float32 数值 +func (r *u3dValues) f32() float32 { + value := math.Float32frombits(r.u32()) + if !finite(float64(value)) && r.err == nil { + r.err = fmt.Errorf("invalid U3D floating-point value") + } + return value +} + +// text 读取带16位字节长度的UTF-8字符串 +// 返回: string 文本 +func (r *u3dValues) text() string { + length := r.take(2) + if length == nil { + return "" + } + data := r.take(int(binary.LittleEndian.Uint16(length))) + if r.err == nil && !utf8.Valid(data) { + r.err = fmt.Errorf("invalid U3D UTF-8 string") + } + return string(data) +} + +// done 检查字段是否恰好覆盖当前块 +// 返回: error 读取或多余字段错误 +func (r *u3dValues) done() error { + if r.err != nil { + return r.err + } + if r.pos != len(r.data) { + return fmt.Errorf("unexpected U3D block data") + } + return nil +} + +// newU3DBits 初始化当前块的独立算术解码状态 +// 入参: data 块数据 +// 返回: *u3dBits 解码器 +func newU3DBits(data []byte) *u3dBits { + r := &u3dBits{data: data, high: 0xffff} + for range 16 { + r.code = r.code<<1 | r.bit() + } + return r +} + +// bit 读取低位优先比特,区间预读最多允许16个末尾零位 +// 返回: uint32 比特 +func (r *u3dBits) bit() uint32 { + if r.err != nil { + return 0 + } + limit := uint64(len(r.data)) * 8 + var value uint32 + if r.pos < limit { + value = uint32(r.data[r.pos/8]>>(r.pos%8)) & 1 + } else if r.pos >= limit+16 { + r.err = io.ErrUnexpectedEOF + return 0 + } + r.pos++ + return value +} + +// cumulative 求当前编码值在上下文累计频次中的位置 +// 入参: total 总频次 +// 返回: uint32 累计频次位置 +func (r *u3dBits) cumulative(total uint32) uint32 { + if r.err != nil { + return 0 + } + if total == 0 || r.code < r.low || r.code > r.high { + r.err = fmt.Errorf("invalid U3D arithmetic interval") + return 0 + } + return uint32((uint64(r.code-r.low+1)*uint64(total) - 1) / uint64(r.high-r.low+1)) +} + +// advance 收缩符号区间并执行同位及下溢归一化 +// 入参: start 累计频次, count 符号频次, total 总频次 +func (r *u3dBits) advance(start, count, total uint32) { + if r.err != nil { + return + } + if count == 0 || start >= total || count > total-start { + r.err = fmt.Errorf("invalid U3D symbol frequency") + return + } + width := uint64(r.high-r.low) + 1 + r.high = r.low + uint32(width*uint64(start+count)/uint64(total)) - 1 + r.low += uint32(width * uint64(start) / uint64(total)) + for r.err == nil { + var offset uint32 + switch { + case r.high < 0x8000: + case r.low >= 0x8000: + offset = 0x8000 + case r.low >= 0x4000 && r.high < 0xc000: + offset = 0x4000 + default: + return + } + r.low = (r.low - offset) << 1 + r.high = (r.high-offset)<<1 | 1 + r.code = (r.code-offset)<<1 | r.bit() + } +} + +// byteValue 逆解静态256符号上下文中的反序字节 +// 返回: byte 原始字节 +func (r *u3dBits) byteValue() byte { + value := r.cumulative(256) + r.advance(value, 1, 256) + return bits.Reverse8(byte(value)) +} + +// u32 逆解四个小端字节,不重置压缩区间 +// 返回: uint32 原始整数 +func (r *u3dBits) u32() uint32 { + var value uint32 + for shift := range 4 { + value |= uint32(r.byteValue()) << (8 * shift) + } + return value +} + +// index 读取静态上下文的网格索引,大范围按标准使用未压缩整数 +// 入参: count 索引范围 +// 返回: uint32 索引 +func (r *u3dBits) index(count uint32) uint32 { + if count > 0x3ffe { + return r.u32() + } + value := r.cumulative(count) + r.advance(value, 1, count) + return value +} + +// dynamic 读取动态上下文数值,逃逸后的字面量仍使用当前算术区间 +// 入参: h 频次上下文 +// 返回: uint32 原始整数 +func (r *u3dBits) dynamic(h *u3dHistogram) uint32 { + if h.total == 0 { + h.counts = make([]uint32, 1) + h.tree = make([]uint32, 2) + h.add(0) + } + index := r.cumulative(h.total) + symbol, start := h.find(index) + if r.err != nil { + return 0 + } + r.advance(start, h.counts[symbol], h.total) + h.add(symbol) + if symbol != 0 { + return symbol - 1 + } + value := r.u32() + if value < 0xffff && r.err == nil { + h.add(value + 1) + } + return value +} + +// find 以树状前缀和定位符号,避免按符号数量线性扫描 +// 入参: position 累计频次位置 +// 返回: uint32 符号, uint32 符号前缀频次 +func (h *u3dHistogram) find(position uint32) (uint32, uint32) { + index, prefix := 0, uint32(0) + for step := 1 << (bits.Len(uint(len(h.tree))) - 1); step != 0; step >>= 1 { + next := index + step + if next < len(h.tree) && h.tree[next] <= position-prefix { + prefix += h.tree[next] + index = next + } + } + return uint32(index), prefix +} + +// add 更新动态频次,并按ECMA-363阈值缩减历史频次 +// 入参: symbol 符号,超过16位的值不加入上下文 +func (h *u3dHistogram) add(symbol uint32) { + if symbol > 0xffff { + return + } + if h.total >= 0x1fff { + for index := range h.counts { + h.counts[index] >>= 1 + } + h.counts[0]++ + h.rebuild() + } + if int(symbol) >= len(h.counts) { + length := min(65536, max(int(symbol)+1, len(h.counts)*2)) + h.counts = append(h.counts, make([]uint32, length-len(h.counts))...) + h.rebuild() + } + h.counts[symbol]++ + h.total++ + for index := int(symbol) + 1; index < len(h.tree); index += index & -index { + h.tree[index]++ + } +} + +// rebuild 在线性时间内重建动态频次索引 +func (h *u3dHistogram) rebuild() { + h.tree = make([]uint32, len(h.counts)+1) + h.total = 0 + for index, count := range h.counts { + h.total += count + i := index + 1 + h.tree[i] += count + if next := i + (i & -i); next < len(h.tree) { + h.tree[next] += h.tree[i] + } + } +} diff --git a/ofdgo_u3d_light.go b/ofdgo_u3d_light.go new file mode 100644 index 0000000..5845363 --- /dev/null +++ b/ofdgo_u3d_light.go @@ -0,0 +1,326 @@ +// 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" +) + +// U3DDirectionalLight 定义无限远光源,Direction为发射方向,CameraRelative使用相机坐标 +type U3DDirectionalLight struct { + Color, Direction [3]float64 + CameraRelative bool +} + +// U3DLighting 替换场景灯光,空灯光列表表示无光源,DiffuseOffset加到受光材质的漫反射颜色 +// 颜色及漫反射增量的各分量取值为0到1;不修改材质自发光或不受光着色器 +type U3DLighting struct { + Lights []U3DDirectionalLight + DiffuseOffset [3]float64 +} + +// u3dRenderLight 保存世界空间的光源位置与正Z发射方向 +type u3dRenderLight struct { + value U3DLight + position, direction u3dVector +} + +// u3dRenderStyle 保存当前着色器的有效材质参数 +type u3dRenderStyle struct { + shader U3DShader + ambient, diffuse, specular, emissive [3]float64 + opacity, exponent float64 +} + +// collectLights 收集本轮根节点下启用的光源实例 +// 返回: error 参数、预算或取消错误 +func (r *u3dRender) collectLights() error { + if r.lighting != nil { + return nil + } + r.lights = r.lights[:0] + for i, instance := range r.scene { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + node := r.model.Nodes[instance.node] + if !r.members[i] || node.Kind != "Light" { + continue + } + j, ok := r.lightIndex[node.Resource] + if !ok { + continue + } + light := r.model.Lights[j] + if light.Attributes&1 == 0 { + continue + } + if light.Attributes > 7 || light.Kind > 3 { + return fmt.Errorf("invalid U3D light") + } + for _, v := range light.Color { + if !finite(float64(v)) { + return fmt.Errorf("invalid U3D light color") + } + } + if light.Kind != 0 && !finite(float64(light.Intensity)) { + return fmt.Errorf("invalid U3D light intensity") + } + if light.Kind >= 2 { + for _, v := range light.Attenuation { + if !finite(float64(v)) { + return fmt.Errorf("invalid U3D light attenuation") + } + } + } + if light.Kind == 3 && (!finite(float64(light.SpotAngle)) || light.SpotAngle <= 0 || light.SpotAngle > 180) { + return fmt.Errorf("invalid U3D spot angle") + } + world := instance.world + item := u3dRenderLight{value: light, position: u3dVector{world[12], world[13], world[14]}, direction: (u3dVector{world[8], world[9], world[10]}).unit()} + if (light.Kind == 1 || light.Kind == 3) && item.direction == (u3dVector{}) { + return fmt.Errorf("invalid U3D light direction") + } + if len(r.lights) == cap(r.lights) { + capacity := max(8, cap(r.lights)*2) + if err := r.reserve(uint64(capacity), 128); err != nil { + return err + } + values := make([]u3dRenderLight, len(r.lights), capacity) + copy(values, r.lights) + r.remaining += uint64(cap(r.lights)) * 128 + r.lights = values + } + r.lights = append(r.lights, item) + } + return nil +} + +// overrideLighting 校验并建立替代光源,不依赖模型光源节点 +// 入参: lighting 灯光覆盖,空值保留模型灯光 +// 返回: error 参数、预算或取消错误 +func (r *u3dRender) overrideLighting(lighting *U3DLighting) error { + if lighting == nil { + return nil + } + for _, v := range lighting.DiffuseOffset { + if !finite(v) || v < 0 || v > 1 { + return fmt.Errorf("invalid U3D diffuse offset") + } + } + if err := r.reserve(uint64(len(lighting.Lights)), 128); err != nil { + return err + } + r.lights = make([]u3dRenderLight, len(lighting.Lights)) + for i, light := range lighting.Lights { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + for j, v := range light.Color { + if !finite(v) || v < 0 || v > 1 || !finite(light.Direction[j]) { + return fmt.Errorf("invalid U3D directional light") + } + } + direction := u3dVector(light.Direction).unit() + if light.CameraRelative && r.camera.ToWorld != ([16]float64{}) { + m := r.camera.ToWorld + direction = u3dVector{m[0]*direction[0] + m[4]*direction[1] + m[8]*direction[2], m[1]*direction[0] + m[5]*direction[1] + m[9]*direction[2], m[2]*direction[0] + m[6]*direction[1] + m[10]*direction[2]}.unit() + } + if direction == (u3dVector{}) { + return fmt.Errorf("invalid U3D directional light direction") + } + r.lights[i] = u3dRenderLight{value: U3DLight{Attributes: 3, Kind: 1, Intensity: 1}, direction: direction} + } + r.lighting = lighting + return nil +} + +// style 解析着色器与材质,未启用的材质项使用固定默认值 +// 入参: 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} + if index, ok := r.shaders[name]; ok { + style.shader = r.model.Shaders[index] + } + shader := style.shader + if shader.Attributes > 7 || shader.Blend < 0x604 || shader.Blend > 0x607 || shader.AlphaFunction < 0x610 || shader.AlphaFunction > 0x617 || shader.Attributes&2 != 0 && !finite(float64(shader.AlphaReference)) { + return style, fmt.Errorf("invalid U3D render shader") + } + if index, ok := r.materials[shader.Material]; ok { + material := r.model.Materials[index] + if material.Attributes > 63 { + return style, fmt.Errorf("invalid U3D render material") + } + for i, item := range []struct { + source [3]float32 + target *[3]float64 + }{{material.Ambient, &style.ambient}, {material.Diffuse, &style.diffuse}, {material.Specular, &style.specular}, {material.Emissive, &style.emissive}} { + if material.Attributes&(1<= 2 { + incoming = light.position.sub(position) + distance := math.Hypot(math.Hypot(incoming[0], incoming[1]), incoming[2]) + denominator := float64(value.Attenuation[0]) + float64(value.Attenuation[1])*distance + float64(value.Attenuation[2])*distance*distance + if !finite(denominator) || denominator == 0 { + return color, fmt.Errorf("invalid U3D point attenuation") + } + factor /= denominator + incoming = incoming.unit() + if value.Kind == 3 { + cosine := -incoming.dot(light.direction) + cutoff := math.Cos(float64(value.SpotAngle) * math.Pi / 360) + if cosine < cutoff { + continue + } + if value.Attributes&4 != 0 && cutoff < 1 { + factor *= min(1, max(0, (cosine-cutoff)/(1-cutoff))) + } + } + } + diffuseAmount := max(0, normal.dot(incoming)) + specularAmount := 0.0 + if diffuseAmount > 0 && style.exponent > 0 && value.Attributes&2 != 0 { + reflection := u3dVector{2*diffuseAmount*normal[0] - incoming[0], 2*diffuseAmount*normal[1] - incoming[1], 2*diffuseAmount*normal[2] - incoming[2]} + specularAmount = math.Pow(max(0, reflection.dot(eye)), style.exponent) + } + for c := range 3 { + lightColor := float64(value.Color[c]) + if r.lighting != nil { + lightColor = r.lighting.Lights[i].Color[c] + } + color[c] += lightColor * factor * (style.diffuse[c]*diffuseAmount + style.specular[c]*specularAmount) + } + } + for _, v := range color { + if !finite(v) { + return color, fmt.Errorf("invalid U3D lighting result") + } + } + return color, nil +} + +// u3dAlphaTest 按ECMA-363枚举比较透明度,不套用图形接口的枚举顺序 +// 入参: function 比较函数, value 透明度, reference 参考值 +// 返回: bool 是否通过 +func u3dAlphaTest(function uint32, value, reference float64) bool { + switch function { + case 0x610: + return false + case 0x611: + return value < reference + case 0x612: + return value > reference + case 0x613: + return value == reference + case 0x614: + return value != reference + case 0x615: + return value <= reference + case 0x616: + return value >= reference + case 0x617: + return true + } + return false +} diff --git a/ofdgo_u3d_line.go b/ofdgo_u3d_line.go new file mode 100644 index 0000000..d114796 --- /dev/null +++ b/ofdgo_u3d_line.go @@ -0,0 +1,231 @@ +// 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" +) + +// frustumPlanes 返回保留正侧的六个相机视锥面 +// 返回: [6][4]float64 近、远、左右及上下平面 +func (r *u3dRender) frustumPlanes() [6][4]float64 { + w, h := r.viewport[2]/2, r.viewport[3]/2 + planes := [6][4]float64{{0, 0, 1, -r.camera.Near}, {0, 0, -1, r.camera.Far}, {r.camera.ScaleX, 0, 0, w + r.camera.ShiftX}, {-r.camera.ScaleX, 0, 0, w - r.camera.ShiftX}, {0, r.camera.ScaleY, 0, h - r.camera.ShiftY}, {0, -r.camera.ScaleY, 0, h + r.camera.ShiftY}} + if r.camera.Perspective { + for i := 2; i < 6; i++ { + planes[i][2], planes[i][3] = planes[i][3], 0 + } + } + return planes +} + +// u3dPlaneDistance 计算未归一化的平面有符号距离 +// 入参: plane 平面方程, point 点坐标 +// 返回: float64 有符号距离 +func u3dPlaneDistance(plane [4]float64, point u3dVector) float64 { + return plane[0]*point[0] + plane[1]*point[1] + plane[2]*point[2] + plane[3] +} + +// u3dClipFraction 计算异侧有限距离的交点比例,避免绝对值求和溢出 +// 入参: a 起点距离, b 终点距离 +// 返回: float64 插值比例 +func u3dClipFraction(a, b float64) float64 { + a, b = math.Abs(a), math.Abs(b) + scale := max(a, b) + if scale == 0 { + return 0 + } + a, b = a/scale, b/scale + return a / (a + b) +} + +// u3dInterpolate 在相机空间插值坐标与未预乘颜色 +// 入参: a 起点, b 终点, t 比例 +// 返回: u3dVertex 插值顶点 +func u3dInterpolate(a, b u3dVertex, t float64) u3dVertex { + var result u3dVertex + for i := range result.point { + result.point[i] = a.point[i]*(1-t) + b.point[i]*t + } + for i := range result.color { + result.color[i] = a.color[i]*(1-t) + b.color[i]*t + } + return result +} + +// sectionIntersection 绘制原三角面与指定剖切面的交线,其他剖切面仍参与裁剪 +// 入参: triangle 原三角面, index 剖切面索引 +// 返回: error 数值、资源或取消错误 +func (r *u3dRender) sectionIntersection(triangle [3]u3dVertex, index int) error { + if err := r.ctx.Err(); err != nil { + return err + } + section := r.sections[index] + var distances [3]float64 + for i, vertex := range triangle { + distances[i] = u3dPlaneDistance(section.plane, vertex.point) + if !finite(distances[i]) { + return fmt.Errorf("invalid U3D intersection distance") + } + } + if distances == ([3]float64{}) { + return nil + } + var points [3]u3dVertex + count := 0 + add := func(vertex u3dVertex) { + for i := range count { + if points[i].point == vertex.point { + return + } + } + if count < len(points) { + points[count], count = vertex, count+1 + } + } + for i, vertex := range triangle { + j := (i + 1) % 3 + if distances[i] == 0 { + add(vertex) + } + if distances[i] < 0 && distances[j] > 0 || distances[i] > 0 && distances[j] < 0 { + add(u3dInterpolate(vertex, triangle[j], u3dClipFraction(distances[i], distances[j]))) + } + } + if count < 2 { + return nil + } + a, b := points[0], points[1] + for i, other := range r.sections { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if i != index { + keep, err := u3dClipLine(&a, &b, other.plane) + if err != nil || !keep { + return err + } + } + } + for i, plane := range r.frustumPlanes() { + if i == 1 && r.camera.Far == 0 && !r.camera.ClipFar { + continue + } + keep, err := u3dClipLine(&a, &b, plane) + if err != nil || !keep { + return err + } + } + normal := u3dTriangleNormal([3]u3dVector{triangle[0].point, triangle[1].point, triangle[2].point}) + return r.rasterSectionLine(a, b, *section.intersection, normal, triangle[0].point) +} + +// u3dClipLine 将线段裁至平面正侧 +// 入参: a 起点, b 终点, plane 平面方程 +// 返回: bool 是否保留, error 数值错误 +func u3dClipLine(a, b *u3dVertex, plane [4]float64) (bool, error) { + da, db := u3dPlaneDistance(plane, a.point), u3dPlaneDistance(plane, b.point) + if !finite(da) || !finite(db) { + return false, fmt.Errorf("invalid U3D line clip distance") + } + if da < 0 && db < 0 { + return false, nil + } + if (da < 0) != (db < 0) { + intersection := u3dInterpolate(*a, *b, u3dClipFraction(da, db)) + if da < 0 { + *a = intersection + } else { + *b = intersection + } + } + return true, nil +} + +// rasterSectionLine 绘制单像素不透明交线,透视深度使用倒数插值 +// 入参: a 已裁剪起点, b 已裁剪终点, rgb 交线颜色, normal 原面法线, origin 原面上一点 +// 返回: error 数值、资源或取消错误 +func (r *u3dRender) rasterSectionLine(a, b u3dVertex, rgb [3]float64, normal, origin u3dVector) error { + cx := r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX + cy := r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY + planes := [4][4]float64{{r.camera.ScaleX, 0, 0, cx}, {-r.camera.ScaleX, 0, 0, float64(r.output.Rect.Dx()) - cx}, {0, -r.camera.ScaleY, 0, cy}, {0, r.camera.ScaleY, 0, float64(r.output.Rect.Dy()) - cy}} + for _, plane := range planes { + if r.camera.Perspective { + plane[2], plane[3] = plane[3], 0 + } + keep, err := u3dClipLine(&a, &b, plane) + if err != nil || !keep { + return err + } + } + var x, y, q [2]float64 + for i, vertex := range [2]u3dVertex{a, b} { + q[i] = 1 + if r.camera.Perspective { + if vertex.point[2] <= 0 { + return fmt.Errorf("invalid U3D line perspective depth") + } + q[i] = 1 / vertex.point[2] + } + x[i] = cx + vertex.point[0]*r.camera.ScaleX*q[i] + y[i] = cy - vertex.point[1]*r.camera.ScaleY*q[i] + if !finite(x[i]) || !finite(y[i]) || !finite(q[i]) { + return fmt.Errorf("invalid U3D projected line") + } + } + steps := math.Ceil(max(math.Abs(x[1]-x[0]), math.Abs(y[1]-y[0]))) + if !finite(steps) || steps > float64(max(r.output.Rect.Dx(), r.output.Rect.Dy()))+2 { + return fmt.Errorf("invalid U3D line span") + } + count := max(1, int(steps)) + color := [4]float64{rgb[0], rgb[1], rgb[2], 1} + r.order++ + for i := 0; i <= count; i++ { + if i%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + t := float64(i) / float64(count) + px, py := math.Floor(x[0]*(1-t)+x[1]*t), math.Floor(y[0]*(1-t)+y[1]*t) + if px < 0 || py < 0 || px >= float64(r.output.Rect.Dx()) || py >= float64(r.output.Rect.Dy()) { + continue + } + depth := a.point[2]*(1-t) + b.point[2]*t + if r.camera.Perspective { + depth = 1 / (q[0]*(1-t) + q[1]*t) + } + ray := u3dVector{(px + .5 - cx) / r.camera.ScaleX, (cy - py - .5) / r.camera.ScaleY, 1} + denominator := normal[2] + numerator := normal.dot(origin) - normal[0]*ray[0] - normal[1]*ray[1] + if r.camera.Perspective { + denominator, numerator = normal.dot(ray), normal.dot(origin) + } + if sampled := numerator / denominator; finite(sampled) && sampled >= r.camera.Near && (r.camera.Far == 0 && !r.camera.ClipFar || sampled <= r.camera.Far) { + depth = sampled + } + depth -= 8 * math.Abs(depth-math.Nextafter(depth, math.Inf(-1))) + pixel := int(py)*r.output.Rect.Dx() + int(px) + if depth <= r.frame[pixel].depth { + if err := r.fragment(pixel, depth, color, 0x606); err != nil { + return err + } + } + } + return nil +} diff --git a/ofdgo_u3d_mesh.go b/ofdgo_u3d_mesh.go new file mode 100644 index 0000000..428ea9c --- /dev/null +++ b/ofdgo_u3d_mesh.go @@ -0,0 +1,295 @@ +// 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" +) + +// U3DMesh 保存完整基础网格的独立数组,索引对应原始作者网格 +type U3DMesh struct { + Name string + ExcludeNormals bool + Positions, Normals [][3]float32 + Diffuse, Specular, TextureCoordinates [][4]float32 + Shadings []U3DShading + Faces []U3DFace + Quality [3]uint32 + InverseQuantization [5]float32 + NormalCrease, NormalUpdate, NormalTolerance float32 +} + +// U3DShading 描述各面使用的颜色及纹理坐标,纹理维数依层排列 +type U3DShading struct { + Attributes uint32 + TextureDimensions []uint32 + OriginalID uint32 +} + +// U3DFace 保存着色描述索引和依原始绕序排列的三个角点 +type U3DFace struct { + Shading uint32 + Corners [3]U3DCorner +} + +// U3DCorner 保存各属性索引,未启用的属性不可用于访问数组 +type U3DCorner struct { + Position, Normal, Diffuse, Specular uint32 + Texture [8]uint32 +} + +// u3dMeshDeclaration 保存基础网格声明及续块接收状态 +type u3dMeshDeclaration struct { + mesh U3DMesh + counts [7]uint32 + decoded bool +} + +// meshDeclaration 读取完整基础网格声明,不将渐进网格当作完整模型 +// 入参: r 字段读取器, name 网格名 +// 返回: error 结构、能力或预算错误 +func (d *u3dDecoder) meshDeclaration(r *u3dValues, name string) error { + if _, exists := d.meshes[name]; exists { + return fmt.Errorf("duplicate U3D mesh %q", name) + } + index, attributes := r.u32(), r.u32() + if index != 0 || attributes > 1 { + return fmt.Errorf("invalid U3D mesh declaration") + } + decl := &u3dMeshDeclaration{mesh: U3DMesh{Name: name, ExcludeNormals: attributes == 1}} + for index := range decl.counts { + decl.counts[index] = r.u32() + } + count := decl.counts[6] + if uint64(count) > uint64(len(r.data)-r.pos)/12 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 40); err != nil { + return err + } + decl.mesh.Shadings = make([]U3DShading, int(count)) + for index := range decl.mesh.Shadings { + shading := &decl.mesh.Shadings[index] + shading.Attributes = r.u32() + layers := r.u32() + if shading.Attributes > 3 { + return fmt.Errorf("invalid U3D shading attributes") + } + if layers > 8 { + return fmt.Errorf("unsupported U3D texture layer count") + } + if err := d.reserve(uint64(layers), 4); err != nil { + return err + } + shading.TextureDimensions = make([]uint32, int(layers)) + for layer := range shading.TextureDimensions { + dimension := r.u32() + if dimension < 1 || dimension > 4 { + return fmt.Errorf("invalid U3D texture coordinate dimension") + } + shading.TextureDimensions[layer] = dimension + } + shading.OriginalID = r.u32() + } + minimum, maximum := r.u32(), r.u32() + for index := range decl.mesh.Quality { + decl.mesh.Quality[index] = r.u32() + } + for index := range decl.mesh.InverseQuantization { + 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 == 0 { + for _, count := range decl.counts[:6] { + if count != 0 { + return fmt.Errorf("invalid U3D empty mesh") + } + } + } + d.meshes[name] = decl + d.meshOrder = append(d.meshOrder, name) + return nil +} + +// baseMesh 读取基础顶点与压缩面索引,保留各角点的独立属性 +// 入参: r 字段读取器, name 网格名 +// 返回: error 结构、能力、预算或取消错误 +func (d *u3dDecoder) baseMesh(r *u3dValues, name string) error { + decl := d.meshes[name] + if decl == nil || decl.decoded || decl.counts[1] == 0 || r.u32() != 0 { + return fmt.Errorf("invalid U3D base mesh reference") + } + var counts [6]uint32 + for index := range counts { + counts[index] = r.u32() + if counts[index] != decl.counts[index] { + return fmt.Errorf("U3D base mesh does not match full resolution declaration") + } + } + if r.err != nil { + return r.err + } + literalBytes := (uint64(counts[1])+uint64(counts[2]))*12 + (uint64(counts[3])+uint64(counts[4])+uint64(counts[5]))*16 + if literalBytes > uint64(len(r.data)-r.pos) { + return io.ErrUnexpectedEOF + } + if err := d.reserve(literalBytes, 1); err != nil { + return err + } + if err := d.reserve(uint64(counts[0]), 148); err != nil { + return err + } + mesh := &decl.mesh + mesh.Positions, mesh.Normals = make([][3]float32, int(counts[1])), make([][3]float32, int(counts[2])) + mesh.Diffuse, mesh.Specular, mesh.TextureCoordinates = make([][4]float32, int(counts[3])), make([][4]float32, int(counts[4])), make([][4]float32, int(counts[5])) + for _, array := range [][][3]float32{mesh.Positions, mesh.Normals} { + for index := range array { + if index%1024 == 0 { + if r.err != nil { + return r.err + } + if err := d.ctx.Err(); err != nil { + return err + } + } + for component := range array[index] { + array[index][component] = r.f32() + } + } + } + for _, array := range [][][4]float32{mesh.Diffuse, mesh.Specular, mesh.TextureCoordinates} { + for index := range array { + if index%1024 == 0 { + if r.err != nil { + return r.err + } + if err := d.ctx.Err(); err != nil { + return err + } + } + for component := range array[index] { + array[index][component] = r.f32() + } + } + } + if r.err != nil { + return r.err + } + mesh.Faces = make([]U3DFace, int(counts[0])) + 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:]) + } + readIndex := func(count uint32) (uint32, error) { + var value uint32 + if compressed == nil { + value = r.u32() + } else { + value = compressed.index(count) + if compressed.err != nil { + return 0, compressed.err + } + } + if r.err != nil { + return 0, r.err + } + if value >= count { + return 0, fmt.Errorf("invalid U3D corner index") + } + return value, nil + } + for index := range mesh.Faces { + if index%256 == 0 { + if err := d.ctx.Err(); err != nil { + return err + } + } + face := &mesh.Faces[index] + if compressed == nil { + face.Shading = r.u32() + } else { + face.Shading = compressed.dynamic(&histogram) + if compressed.err != nil { + return compressed.err + } + } + if r.err != nil { + return r.err + } + if uint64(face.Shading) >= uint64(len(mesh.Shadings)) { + return fmt.Errorf("invalid U3D face shading index") + } + shading := &mesh.Shadings[face.Shading] + for cornerIndex := range face.Corners { + corner := &face.Corners[cornerIndex] + var err error + if corner.Position, err = readIndex(counts[1]); err != nil { + return err + } + if !mesh.ExcludeNormals { + if corner.Normal, err = readIndex(counts[2]); err != nil { + return err + } + } + if shading.Attributes&1 != 0 { + if corner.Diffuse, err = readIndex(counts[3]); err != nil { + return err + } + } + if shading.Attributes&2 != 0 { + if corner.Specular, err = readIndex(counts[4]); err != nil { + return err + } + } + for layer := range shading.TextureDimensions { + if corner.Texture[layer], err = readIndex(counts[5]); err != nil { + return err + } + } + } + } + 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)) + } + r.pos = len(r.data) + } + decl.decoded = true + return r.done() +} diff --git a/ofdgo_u3d_render.go b/ofdgo_u3d_render.go new file mode 100644 index 0000000..c72b1e7 --- /dev/null +++ b/ofdgo_u3d_render.go @@ -0,0 +1,443 @@ +// 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" + "image" + "math" +) + +// U3DCamera 定义沿正Z方向观察的相机,ScaleX和ScaleY为投影到像素的倍率 +// ToWorld按列存储,全零表示单位矩阵;Far为0且ClipFar为假时不设置远裁剪面 +// ShiftX和ShiftY为投影原点相对视口中心的像素偏移,向右及向下为正 +type U3DCamera struct { + ToWorld [16]float64 + Perspective bool + ScaleX, ScaleY, Near, Far float64 + ShiftX, ShiftY float64 + ClipFar bool +} + +// U3DRenderOptions 控制静态绘制,指定Camera时使用整个输出视口 +// Camera为空时使用View指定的节点或首个视图节点,Instance指定其父路径实例 +// Lighting为空时沿用模型灯光,非空时替换全部模型灯光 +// Nodes按名称覆盖节点,重复名称使用最后一项,每次绘制均从原场景开始 +// Background为空时使用白色;缓存与实例上限为0时分别使用256MiB和65536,不含输入及运行时开销 +type U3DRenderOptions struct { + Width, Height int + View string + Instance int + Camera *U3DCamera + Background *[3]float64 + Lighting *U3DLighting + Nodes []U3DNodeState + Sections []U3DCrossSection + MaxMemoryBytes int64 + MaxInstances int +} + +// u3dPixel 保存不透明底色、最近深度及透明片元链 +type u3dPixel struct { + color [3]float64 + depth float64 + order uint64 + head int32 +} + +// u3dFragment 保存等待按像素深度混合的透明或非覆盖片元 +type u3dFragment struct { + color [4]float64 + depth float64 + order uint64 + blend uint32 + next int32 +} + +// u3dRender 保存一次绘制的有限预算,不修改调用方场景 +type u3dRender struct { + ctx context.Context + model *U3DModel + remaining uint64 + frame []u3dPixel + output *image.RGBA + viewport [4]float64 + camera U3DCamera + toCamera u3dMatrix + scene []u3dInstance + byNode [][]int + members []bool + meshes, shaders, materials, lightIndex map[string]int + lights []u3dRenderLight + lighting *U3DLighting + states map[string]U3DNodeState + sections []u3dSection + sectionScratch [2][]u3dVertex + chunks [][]u3dFragment + fragments int + scratch []int + order uint64 +} + +// RenderU3D 绘制静态无纹理三角面,返回不透明图像,不执行脚本或动画 +// 采用逐顶点光照及逐像素深度混合,缺失资源回退为空网格、灰色材质或无光源 +// 入参: ctx 取消上下文, model 场景, options 视图、输出与资源限制 +// 返回: *image.RGBA 图像, error 参数、能力、预算或取消错误 +func RenderU3D(ctx context.Context, model *U3DModel, options U3DRenderOptions) (*image.RGBA, error) { + r, err := newU3DRender(ctx, model, options) + if err != nil { + return nil, err + } + passes, err := r.selectView(options) + if err != nil { + return nil, err + } + return r.render(options, passes) +} + +// newU3DRender 建立有预算限制的场景实例与资源索引 +// 入参: ctx 取消上下文, model 场景, options 输出与资源限制 +// 返回: *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 { + 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)} + if uint64(options.Width) > uint64(^uint(0)>>1)/uint64(options.Height) { + return nil, fmt.Errorf("invalid U3D render dimensions") + } + pixels := uint64(options.Width) * uint64(options.Height) + if err := r.reserve(pixels, 60); err != nil { + return nil, err + } + maxInstances := options.MaxInstances + if maxInstances == 0 { + maxInstances = 65536 + } + if err := r.nodeStates(options.Nodes); err != nil { + return nil, err + } + if err := r.instances(maxInstances); 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 +} + +// render 在已选择的相机和轮次下生成图像 +// 入参: options 输出与灯光背景, passes 绘制轮次 +// 返回: *image.RGBA 图像, error 参数、绘制、预算或取消错误 +func (r *u3dRender) render(options U3DRenderOptions, passes []U3DViewPass) (*image.RGBA, error) { + if r.sections == nil { + if err := r.prepareSections(options.Sections); err != nil { + return nil, err + } + } + if err := r.overrideLighting(options.Lighting); err != nil { + return nil, err + } + background := [3]float64{1, 1, 1} + if options.Background != nil { + background = *options.Background + } + for i, v := range background { + if !finite(v) { + return nil, fmt.Errorf("invalid U3D background") + } + background[i] = min(1, max(0, v)) + } + r.frame = make([]u3dPixel, options.Width*options.Height) + r.output = image.NewRGBA(image.Rect(0, 0, options.Width, options.Height)) + for i := range r.frame { + if i%4096 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + r.frame[i].color = background + } + for passIndex, pass := range passes { + if err := r.renderPass(pass, passIndex, passIndex == len(passes)-1); err != nil { + return nil, err + } + } + for i, pixel := range r.frame { + if i%4096 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + for component, v := range pixel.color { + r.output.Pix[i*4+component] = byte(math.Round(min(1, max(0, v)) * 255)) + } + r.output.Pix[i*4+3] = 255 + } + if err := r.ctx.Err(); err != nil { + return nil, err + } + return r.output, nil +} + +// reserve 在分配绘制缓存前扣减预算并检查取消状态 +// 入参: count 元素数量, size 元素预算 +// 返回: error 预算或取消错误 +func (r *u3dRender) reserve(count, size uint64) error { + if err := r.ctx.Err(); err != nil { + return err + } + if size == 0 || count > r.remaining/size || count > uint64(^uint(0)>>1)/size { + return fmt.Errorf("U3D render memory exceeds limit") + } + r.remaining -= count * size + return nil +} + +// selectView 解析视图与相机覆盖,内部视口使用左上角坐标 +// 入参: options 绘制选项 +// 返回: []U3DViewPass 绘制轮次, error 视图或投影错误 +func (r *u3dRender) selectView(options U3DRenderOptions) ([]U3DViewPass, error) { + r.viewport = [4]float64{0, 0, float64(options.Width), float64(options.Height)} + selected := -1 + for i, node := range r.model.Nodes { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return nil, err + } + } + if options.View != "" && node.Name == options.View || options.View == "" && options.Camera == nil && node.Kind == "View" { + selected = i + break + } + } + if options.View != "" && selected < 0 { + return nil, fmt.Errorf("missing U3D view %q", options.View) + } + passes := []U3DViewPass{{}} + if selected >= 0 { + node := r.model.Nodes[selected] + if node.Kind != "View" || node.View == nil { + 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") + } + 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 + } + } + if options.Camera == nil { + if options.Instance >= len(r.byNode[selected]) { + return nil, fmt.Errorf("missing U3D camera instance") + } + r.camera.ToWorld = r.scene[r.byNode[selected][options.Instance]].world + if view.Attributes & ^uint32(7) != 0 || view.Attributes&6 >= 4 { + return nil, fmt.Errorf("unsupported U3D view projection") + } + for i, v := range view.Viewport { + if !finite(float64(v)) { + return nil, fmt.Errorf("invalid U3D viewport") + } + r.viewport[(i+2)%4] = float64(v) + } + if view.Attributes&1 != 0 { + r.viewport[0] *= float64(options.Width) + r.viewport[1] *= float64(options.Height) + r.viewport[2] *= float64(options.Width) + r.viewport[3] *= float64(options.Height) + } + if r.viewport[2] <= 0 || r.viewport[3] <= 0 { + return nil, fmt.Errorf("invalid U3D viewport size") + } + r.camera.Near, r.camera.Far = float64(view.Near), float64(view.Far) + if r.camera.Far <= r.camera.Near { + return nil, fmt.Errorf("invalid U3D view clipping range") + } + r.camera.Perspective = view.Attributes&6 == 0 + if r.camera.Perspective { + if view.Projection <= 0 || view.Projection >= 180 { + return nil, fmt.Errorf("invalid U3D perspective field of view") + } + r.camera.ScaleY = r.viewport[3] / (2 * math.Tan(float64(view.Projection)*math.Pi/360)) + } else { + if view.Projection <= 0 { + return nil, fmt.Errorf("invalid U3D orthographic height") + } + r.camera.ScaleY = r.viewport[3] / float64(view.Projection) + } + r.camera.ScaleX = r.camera.ScaleY + } + } + if options.Camera != nil { + r.camera = *options.Camera + } else if selected < 0 { + return nil, fmt.Errorf("missing U3D camera") + } + if len(passes) > 32 { + return nil, fmt.Errorf("unsupported U3D render pass count") + } + for _, v := range []float64{r.camera.ScaleX, r.camera.ScaleY, r.camera.Near, r.camera.Far, r.camera.ShiftX, r.camera.ShiftY} { + if !finite(v) { + return nil, fmt.Errorf("invalid U3D camera values") + } + } + if r.camera.ScaleX <= 0 || r.camera.ScaleY <= 0 || r.camera.Perspective && r.camera.Near <= 0 || (r.camera.ClipFar || r.camera.Far != 0) && r.camera.Far < r.camera.Near { + return nil, fmt.Errorf("invalid U3D camera projection") + } + matrix := u3dMatrix(r.camera.ToWorld) + if matrix == (u3dMatrix{}) { + matrix = u3dIdentity() + } + var ok bool + r.toCamera, ok = matrix.inverse() + if !ok { + return nil, fmt.Errorf("invalid U3D camera transform") + } + return passes, nil +} + +// 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)) + if err := r.reserve(count, 96); err != nil { + return err + } + r.meshes = make(map[string]int, len(r.model.Meshes)) + 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)) + 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 _, ok := index[name]; ok { + return fmt.Errorf("duplicate U3D render resource %q", name) + } + index[name] = i + return nil + } + for i, v := range r.model.Meshes { + if err := add(r.meshes, v.Name, i); err != nil { + return err + } + } + for i, v := range r.model.Shaders { + if err := add(r.shaders, v.Name, i); err != nil { + return err + } + } + for i, v := range r.model.Materials { + if err := add(r.materials, v.Name, i); err != nil { + return err + } + } + for i, v := range r.model.Lights { + if err := add(r.lightIndex, v.Name, i); err != nil { + return err + } + } + return nil +} + +// renderPass 分别解析遮挡与混合,绘制轮次之间保留颜色并重置深度 +// 入参: pass 轮次参数, passIndex 轮次索引, last 是否为最后一轮 +// 返回: error 场景、预算或取消错误 +func (r *u3dRender) renderPass(pass U3DViewPass, passIndex int, last bool) error { + if pass.Attributes > 1 || pass.FogMode > 2 { + return fmt.Errorf("invalid U3D render pass") + } + if pass.Attributes&1 != 0 { + for _, v := range append(pass.FogColor[:], pass.FogNear, pass.FogFar) { + if !finite(float64(v)) { + return fmt.Errorf("invalid U3D fog value") + } + } + if pass.FogMode == 0 && pass.FogFar <= pass.FogNear || pass.FogMode != 0 && pass.FogFar <= 0 { + return fmt.Errorf("invalid U3D fog range") + } + } + for i, instance := range r.scene { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + r.members[i] = pass.Root == "" || r.model.Nodes[instance.node].Name == pass.Root || instance.parent >= 0 && r.members[instance.parent] + } + for i := range r.frame { + if i%4096 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + r.frame[i].depth = math.Inf(1) + r.frame[i].order = 0 + r.frame[i].head = -1 + } + r.fragments = 0 + if err := r.collectLights(); err != nil { + return err + } + for i, instance := range r.scene { + if err := r.ctx.Err(); err != nil { + return err + } + node := &r.model.Nodes[instance.node] + if !r.members[i] || node.Kind != "Model" || instance.modelVisibility(node) == 0 || instance.opacity == 0 { + continue + } + if node.Visibility > 3 { + return fmt.Errorf("invalid U3D model visibility") + } + mesh, ok := r.meshes[node.Resource] + if !ok { + continue + } + if err := r.drawMesh(instance, node, &r.model.Meshes[mesh], pass, passIndex); err != nil { + return err + } + } + if last { + if err := r.drawSectionPlanes(); err != nil { + return err + } + } + return r.resolveFragments() +} diff --git a/ofdgo_u3d_scene.go b/ofdgo_u3d_scene.go new file mode 100644 index 0000000..2a64087 --- /dev/null +++ b/ofdgo_u3d_scene.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" + "math" +) + +// u3dVector 保存计算过程中的三维双精度坐标 +type u3dVector [3]float64 + +// u3dMatrix 保存按列排列的仿射矩阵 +type u3dMatrix [16]float64 + +// u3dInstance 保存一个父路径产生的世界实例,parent为负值时连接默认根 +type u3dInstance struct { + node, parent int + world u3dMatrix + opacity float64 + visibility int8 +} + +// u3dIdentity 创建单位矩阵 +// 返回: u3dMatrix 单位矩阵 +func u3dIdentity() u3dMatrix { return u3dMatrix{1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1} } + +// multiply 连接子节点到父节点的变换 +// 入参: other 子变换 +// 返回: u3dMatrix 组合变换 +func (m u3dMatrix) multiply(other u3dMatrix) u3dMatrix { + var result u3dMatrix + for column := range 4 { + for row := range 4 { + for k := range 4 { + result[column*4+row] += m[k*4+row] * other[column*4+k] + } + } + } + return result +} + +// valid 判断矩阵是否为有限仿射变换 +// 返回: bool 是否可用于静态绘制 +func (m u3dMatrix) valid() bool { + for _, v := range m { + if !finite(v) { + return false + } + } + return m[3] == 0 && m[7] == 0 && m[11] == 0 && m[15] == 1 +} + +// point 将点从局部坐标映射到目标坐标 +// 入参: p 局部点 +// 返回: u3dVector 目标点 +func (m u3dMatrix) point(p u3dVector) u3dVector { + return u3dVector{m[0]*p[0] + m[4]*p[1] + m[8]*p[2] + m[12], m[1]*p[0] + m[5]*p[1] + m[9]*p[2] + m[13], m[2]*p[0] + m[6]*p[1] + m[10]*p[2] + m[14]} +} + +// inverse 通过带主元消元计算仿射逆矩阵 +// 返回: u3dMatrix 逆矩阵, bool 是否存在有限逆矩阵 +func (m u3dMatrix) inverse() (u3dMatrix, bool) { + if !m.valid() { + return u3dMatrix{}, false + } + var rows [3][6]float64 + for i := range 3 { + for j := range 3 { + rows[i][j] = m[j*4+i] + } + rows[i][i+3] = 1 + } + for column := range 3 { + pivot := column + for row := column + 1; row < 3; row++ { + if math.Abs(rows[row][column]) > math.Abs(rows[pivot][column]) { + pivot = row + } + } + if rows[pivot][column] == 0 { + return u3dMatrix{}, false + } + rows[pivot], rows[column] = rows[column], rows[pivot] + divisor := rows[column][column] + for j := range 6 { + rows[column][j] /= divisor + } + for row := range 3 { + if row != column { + factor := rows[row][column] + for j := range 6 { + rows[row][j] -= factor * rows[column][j] + } + } + } + } + result := u3dIdentity() + for row := range 3 { + for col := range 3 { + result[col*4+row] = rows[row][col+3] + } + result[12+row] = -(result[row]*m[12] + result[4+row]*m[13] + result[8+row]*m[14]) + } + return result, result.valid() +} + +// normal 以逆矩阵的转置变换并归一化法线 +// 入参: n 局部法线,接收者为局部变换的逆矩阵 +// 返回: u3dVector 目标法线 +func (m u3dMatrix) normal(n u3dVector) u3dVector { + return (u3dVector{m[0]*n[0] + m[1]*n[1] + m[2]*n[2], m[4]*n[0] + m[5]*n[1] + m[6]*n[2], m[8]*n[0] + m[9]*n[1] + m[10]*n[2]}).unit() +} + +// unit 归一化向量,零向量保持为零 +// 返回: u3dVector 单位向量 +func (v u3dVector) unit() u3dVector { + scale := max(math.Abs(v[0]), math.Abs(v[1]), math.Abs(v[2])) + if scale == 0 || !finite(scale) { + return u3dVector{} + } + for i := range v { + v[i] /= scale + } + length := math.Sqrt(v.dot(v)) + return u3dVector{v[0] / length, v[1] / length, v[2] / length} +} + +// sub 求向量差 +// 入参: other 被减向量 +// 返回: u3dVector 差向量 +func (v u3dVector) sub(other u3dVector) u3dVector { + return u3dVector{v[0] - other[0], v[1] - other[1], v[2] - other[2]} +} + +// dot 求向量内积 +// 入参: other 另一向量 +// 返回: float64 内积 +func (v u3dVector) dot(other u3dVector) float64 { return v[0]*other[0] + v[1]*other[1] + v[2]*other[2] } + +// cross 求向量叉积 +// 入参: other 另一向量 +// 返回: u3dVector 叉积 +func (v u3dVector) cross(other u3dVector) u3dVector { + return u3dVector{v[1]*other[2] - v[2]*other[1], v[2]*other[0] - v[0]*other[2], v[0]*other[1] - v[1]*other[0]} +} + +// u3dTriangleNormal 选取夹角更稳定的顶点计算法线,避免长瘦三角面的相消 +// 入参: points 按绕序排列的顶点 +// 返回: u3dVector 单位法线,退化三角面返回零向量 +func u3dTriangleNormal(points [3]u3dVector) u3dVector { + var normal u3dVector + score := 0.0 + for i := range 3 { + a := points[(i+1)%3].sub(points[i]).unit() + b := points[(i+2)%3].sub(points[i]).unit() + candidate := a.cross(b) + if length := max(math.Abs(candidate[0]), math.Abs(candidate[1]), math.Abs(candidate[2])); length > score { + normal, score = candidate, length + } + } + return normal.unit() +} + +// instances 按拓扑顺序构建全部父路径实例,限制共享层级的指数展开 +// 入参: limit 最大实例数 +// 返回: error 结构、预算、实例上限或取消错误 +func (r *u3dRender) instances(limit int) error { + nodes := r.model.Nodes + if err := r.reserve(uint64(len(nodes)), 160); err != nil { + return 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 + } + } + if _, exists := index[node.Name]; exists || node.Name == "" { + return fmt.Errorf("invalid U3D render node name") + } + switch node.Kind { + case "Group", "Model", "Light", "View": + default: + return 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)) + for i, node := range nodes { + if err := r.reserve(uint64(len(node.Parents)), 16); err != nil { + return err + } + for k, parent := range node.Parents { + if k%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if j, ok := index[parent.Name]; ok { + counts[i]++ + children[j] = append(children[j], i) + } + } + } + queue := make([]int, 0, len(nodes)) + for i, n := range counts { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if n == 0 { + queue = append(queue, i) + } + } + for cursor := 0; cursor < len(queue); cursor++ { + if err := r.ctx.Err(); err != nil { + return err + } + i := queue[cursor] + state := r.states[nodes[i].Name] + for parentIndex, parent := range nodes[i].Parents { + if parentIndex%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + var local u3dMatrix + for k, v := range parent.Transform { + local[k] = float64(v) + } + 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") + } + if err := r.reserve(1, 384); err != nil { + return err + } + world := local + opacity, visibility := -1.0, int8(-1) + if p >= 0 { + world = r.scene[p].world.multiply(local) + opacity, visibility = r.scene[p].opacity, r.scene[p].visibility + } + if state.Opacity != nil { + opacity = *state.Opacity + } + if state.Visible != nil { + visibility = 0 + if *state.Visible { + visibility = 1 + } + } + if !world.valid() { + return fmt.Errorf("invalid U3D world transform") + } + r.byNode[i] = append(r.byNode[i], len(r.scene)) + 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 +} diff --git a/ofdgo_u3d_section.go b/ofdgo_u3d_section.go new file mode 100644 index 0000000..63831a1 --- /dev/null +++ b/ofdgo_u3d_section.go @@ -0,0 +1,280 @@ +// 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" +) + +// U3DCrossSection 定义世界坐标中的剖切面,AxisU与AxisV须为非零正交方向 +// 两轴叉积指向被裁去的一侧;平面方形边长取静态场景包围盒对角线 +// Opacity为0时不显示平面,Intersection为空时不显示交线,颜色分量范围为0到1 +type U3DCrossSection struct { + Center, AxisU, AxisV [3]float64 + Opacity float64 + Color [3]float64 + Intersection *[3]float64 +} + +// u3dSection 保存相机空间裁剪方程、平面四角与交线颜色 +type u3dSection struct { + plane [4]float64 + corners [4]u3dVertex + intersection *[3]float64 + opacity float64 +} + +// prepareSections 准备剖切缓存,不复制网格或修改调用方参数 +// 入参: sections 世界坐标剖切面 +// 返回: error 参数、场景、预算或取消错误 +func (r *u3dRender) prepareSections(sections []U3DCrossSection) error { + if len(sections) == 0 { + return nil + } + if err := r.reserve(uint64(len(sections)), 352); err != nil { + return err + } + if err := r.reserve(uint64(len(sections))+3, 112); err != nil { + return err + } + r.sections = make([]u3dSection, len(sections)) + for i := range r.sectionScratch { + r.sectionScratch[i] = make([]u3dVertex, len(sections)+3) + } + visible := false + for i, section := range sections { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if !finite(section.Opacity) || section.Opacity < 0 || section.Opacity > 1 { + return fmt.Errorf("invalid U3D section opacity") + } + visible = visible || section.Opacity > 0 + } + diagonal := 0.0 + if visible { + var err error + diagonal, err = r.sceneDiagonal() + if err != nil { + return err + } + } + world := u3dMatrix(r.camera.ToWorld) + if world == (u3dMatrix{}) { + world = u3dIdentity() + } + for i, section := range sections { + if err := r.ctx.Err(); err != nil { + return err + } + for _, vector := range [][3]float64{section.Center, section.AxisU, section.AxisV} { + for _, value := range vector { + if !finite(value) { + return fmt.Errorf("invalid U3D section coordinates") + } + } + } + for _, color := range []*[3]float64{§ion.Color, section.Intersection} { + if color != nil { + for _, v := range color { + if !finite(v) || v < 0 || v > 1 { + return fmt.Errorf("invalid U3D section color") + } + } + } + } + u, v := u3dVector(section.AxisU).unit(), u3dVector(section.AxisV).unit() + if u == (u3dVector{}) || v == (u3dVector{}) || math.Abs(u.dot(v)) > 1e-12 { + return fmt.Errorf("invalid U3D section axes") + } + normal := u.cross(v).unit() + prepared := &r.sections[i] + for j := range 3 { + prepared.plane[j] = -(normal[0]*world[j*4] + normal[1]*world[j*4+1] + normal[2]*world[j*4+2]) + } + prepared.plane[3] = normal.dot(u3dVector(section.Center).sub(u3dVector{world[12], world[13], world[14]})) + for _, coefficient := range prepared.plane { + if !finite(coefficient) { + return fmt.Errorf("invalid U3D section plane") + } + } + prepared.intersection, prepared.opacity = section.Intersection, section.Opacity + for j, signs := range [4][2]float64{{-1, -1}, {1, -1}, {1, 1}, {-1, 1}} { + point := u3dVector(section.Center) + for k := range point { + point[k] += diagonal / 2 * (signs[0]*u[k] + signs[1]*v[k]) + } + prepared.corners[j] = u3dVertex{point: r.toCamera.point(point), color: [4]float64{section.Color[0], section.Color[1], section.Color[2], section.Opacity}} + for _, coordinate := range prepared.corners[j].point { + if !finite(coordinate) { + return fmt.Errorf("invalid U3D section corner") + } + } + } + } + return nil +} + +// sceneDiagonal 计算静态场景世界包围盒的对角线,包含隐藏节点的几何 +// 返回: float64 对角线长度, error 几何、数值或取消错误 +func (r *u3dRender) sceneDiagonal() (float64, error) { + minimum := u3dVector{math.Inf(1), math.Inf(1), math.Inf(1)} + maximum := u3dVector{math.Inf(-1), math.Inf(-1), math.Inf(-1)} + for _, instance := range r.scene { + if err := r.ctx.Err(); err != nil { + return 0, err + } + node := r.model.Nodes[instance.node] + index, exists := r.meshes[node.Resource] + if node.Kind != "Model" || !exists { + continue + } + mesh := &r.model.Meshes[index] + for i, face := range mesh.Faces { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return 0, err + } + } + for _, corner := range face.Corners { + if uint64(corner.Position) >= uint64(len(mesh.Positions)) { + return 0, fmt.Errorf("invalid U3D position index") + } + p := mesh.Positions[corner.Position] + world := instance.world.point(u3dVector{float64(p[0]), float64(p[1]), float64(p[2])}) + for j, v := range world { + if !finite(v) { + return 0, fmt.Errorf("invalid U3D scene bounds") + } + minimum[j], maximum[j] = min(minimum[j], v), max(maximum[j], v) + } + } + } + } + if math.IsInf(minimum[0], 1) { + return 0, nil + } + delta := maximum.sub(minimum) + diagonal := math.Hypot(math.Hypot(delta[0], delta[1]), delta[2]) + if !finite(diagonal) { + return 0, fmt.Errorf("invalid U3D scene diagonal") + } + return diagonal, nil +} + +// sectionTriangle 用复用的凸多边形缓存应用全部剖切面,再进行视锥裁剪 +// 入参: triangle 三角面, shader 着色器, pass 绘制轮次 +// 返回: error 数值、资源或取消错误 +func (r *u3dRender) sectionTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error { + if len(r.sections) == 0 { + return r.clipTriangle(triangle, shader, pass) + } + copy(r.sectionScratch[0], triangle[:]) + size, current := 3, 0 + for _, section := range r.sections { + if err := r.ctx.Err(); err != nil { + return err + } + next, count := 1-current, 0 + previous := r.sectionScratch[current][size-1] + previousDistance := u3dPlaneDistance(section.plane, previous.point) + for i, vertex := range r.sectionScratch[current][:size] { + if i%256 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + distance := u3dPlaneDistance(section.plane, vertex.point) + if !finite(distance) || !finite(previousDistance) { + return fmt.Errorf("invalid U3D section distance") + } + if (distance >= 0) != (previousDistance >= 0) { + var err error + count, err = u3dAppendSectionVertex(r.sectionScratch[next], count, u3dInterpolate(previous, vertex, u3dClipFraction(previousDistance, distance))) + if err != nil { + return err + } + } + if distance >= 0 { + var err error + count, err = u3dAppendSectionVertex(r.sectionScratch[next], count, vertex) + if err != nil { + return err + } + } + previous, previousDistance = vertex, distance + } + if count > 1 && r.sectionScratch[next][0].point == r.sectionScratch[next][count-1].point { + count-- + } + size, current = count, next + if size < 3 { + break + } + } + if size < 2 { + return nil + } + for i := 1; i+1 < size; i++ { + if err := r.clipTriangle([3]u3dVertex{r.sectionScratch[current][0], r.sectionScratch[current][i], r.sectionScratch[current][i+1]}, shader, pass); err != nil { + return err + } + } + for i, section := range r.sections { + if section.intersection != nil { + if err := r.sectionIntersection(triangle, i); err != nil { + return err + } + } + } + return nil +} + +// u3dAppendSectionVertex 合并连续重复交点,维持凸多边形缓存上限 +// 入参: buffer 固定缓存, count 已有顶点数, vertex 新顶点 +// 返回: int 顶点数, error 缓存越界错误 +func u3dAppendSectionVertex(buffer []u3dVertex, count int, vertex u3dVertex) (int, error) { + if count > 0 && buffer[count-1].point == vertex.point { + return count, nil + } + if count >= len(buffer) { + return count, fmt.Errorf("invalid U3D section polygon") + } + buffer[count] = vertex + return count + 1, nil +} + +// drawSectionPlanes 在最后一轮绘制平面方形,不把平面本身当作待剖切几何 +// 返回: error 数值、资源或取消错误 +func (r *u3dRender) drawSectionPlanes() error { + for _, section := range r.sections { + if err := r.ctx.Err(); err != nil { + return err + } + if section.opacity == 0 { + continue + } + for i := 1; i < 3; i++ { + r.order++ + if err := r.clipTriangle([3]u3dVertex{section.corners[0], section.corners[i], section.corners[i+1]}, U3DShader{Blend: 0x606}, U3DViewPass{}); err != nil { + return err + } + } + } + return nil +} diff --git a/ofdgo_u3d_state.go b/ofdgo_u3d_state.go new file mode 100644 index 0000000..2ec5ffe --- /dev/null +++ b/ofdgo_u3d_state.go @@ -0,0 +1,73 @@ +// 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" + +// U3DNodeState 按名称覆盖节点,未指定透明度或可见性时沿父路径继承 +// Opacity范围为0到1,采用标准透明混合;Matrix为相对父节点的列式仿射矩阵 +// 未匹配的名称不影响场景,矩阵为空时保留每条父路径的原始变换 +type U3DNodeState struct { + Name string + Opacity *float64 + Visible *bool + Matrix *[16]float64 +} + +// nodeStates 建立有预算限制的覆盖索引,重复名称整体替换而不合并字段 +// 入参: states 节点覆盖 +// 返回: error 数值、预算或取消错误 +func (r *u3dRender) nodeStates(states []U3DNodeState) error { + if len(states) == 0 { + return nil + } + if err := r.reserve(uint64(len(states)), 128); 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[state.Name] = state + } + for _, state := range r.states { + if err := r.ctx.Err(); err != nil { + return err + } + if state.Opacity != nil && (!finite(*state.Opacity) || *state.Opacity < 0 || *state.Opacity > 1) { + return fmt.Errorf("invalid U3D node opacity") + } + if state.Matrix != nil && !u3dMatrix(*state.Matrix).valid() { + return fmt.Errorf("invalid U3D node matrix") + } + } + return nil +} + +// modelVisibility 在节点覆盖生效时恢复隐藏模型,保留原有正反面选择 +// 入参: node 模型节点 +// 返回: uint32 可见面标志 +func (instance u3dInstance) modelVisibility(node *U3DNode) uint32 { + if instance.visibility == 0 { + return 0 + } + if instance.visibility == 1 && node.Visibility == 0 { + return 3 + } + return node.Visibility +} diff --git a/ofdgo_u3d_triangle.go b/ofdgo_u3d_triangle.go new file mode 100644 index 0000000..b9f4d15 --- /dev/null +++ b/ofdgo_u3d_triangle.go @@ -0,0 +1,464 @@ +// 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" +) + +// u3dVertex 保存裁剪前后的相机坐标及未预乘颜色 +type u3dVertex struct { + point u3dVector + color [4]float64 +} + +// drawMesh 按实例复用顶点变换,着色列表按指定顺序绘制 +// 入参: instance 场景实例, node 模型节点, mesh 网格, pass 绘制轮次, passIndex 轮次索引 +// 返回: error 几何、资源或取消错误 +func (r *u3dRender) drawMesh(instance u3dInstance, node *U3DNode, mesh *U3DMesh, pass U3DViewPass, passIndex int) error { + count := uint64(len(mesh.Positions)) + uint64(len(mesh.Normals)) + if err := r.reserve(count, 24); err != nil { + return err + } + defer func() { r.remaining += count * 24 }() + positions := make([]u3dVector, len(mesh.Positions)) + normals := make([]u3dVector, len(mesh.Normals)) + inverse, invertible := instance.world.inverse() + for i, p := range mesh.Positions { + if i%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + positions[i] = instance.world.point(u3dVector{float64(p[0]), float64(p[1]), float64(p[2])}) + for _, v := range positions[i] { + if !finite(v) { + return fmt.Errorf("invalid U3D transformed position") + } + } + } + for i, n := range mesh.Normals { + if i%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + for _, v := range n { + if !finite(float64(v)) { + return fmt.Errorf("invalid U3D normal") + } + } + if invertible { + normals[i] = inverse.normal(u3dVector{float64(n[0]), float64(n[1]), float64(n[2])}) + } + } + for index, face := range mesh.Faces { + if index%128 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + if uint64(face.Shading) >= uint64(len(mesh.Shadings)) { + return fmt.Errorf("invalid U3D render shading index") + } + shading := mesh.Shadings[face.Shading] + if shading.Attributes > 3 || len(shading.TextureDimensions) > 8 { + return fmt.Errorf("invalid U3D render shading") + } + var world [3]u3dVector + var vertices [3]u3dVertex + for i, corner := range face.Corners { + if uint64(corner.Position) >= uint64(len(positions)) || !mesh.ExcludeNormals && uint64(corner.Normal) >= uint64(len(normals)) || shading.Attributes&1 != 0 && uint64(corner.Diffuse) >= uint64(len(mesh.Diffuse)) || shading.Attributes&2 != 0 && uint64(corner.Specular) >= uint64(len(mesh.Specular)) { + return fmt.Errorf("invalid U3D render corner index") + } + world[i] = positions[corner.Position] + vertices[i].point = r.toCamera.point(world[i]) + for _, v := range vertices[i].point { + if !finite(v) { + return fmt.Errorf("invalid U3D camera position") + } + } + } + var faceNormal u3dVector + if mesh.ExcludeNormals || !invertible { + faceNormal = u3dTriangleNormal(world) + } + p0, p1, p2 := vertices[0].point, vertices[1].point, vertices[2].point + facing := (p1[0]-p0[0])*(p2[1]-p0[1]) - (p1[1]-p0[1])*(p2[0]-p0[0]) + if r.camera.Perspective { + facing = p0.unit().dot(p1.unit().cross(p2.unit())) + } + if !finite(facing) { + return fmt.Errorf("invalid U3D face orientation") + } + front := facing < 0 + visibility := instance.modelVisibility(node) + if facing == 0 || front && visibility&1 == 0 || !front && visibility&2 == 0 { + continue + } + shaderNames := []string{""} + if uint64(face.Shading) < uint64(len(node.Shaders)) { + shaderNames = node.Shaders[face.Shading] + } + for shaderIndex, name := range shaderNames { + if shaderIndex%128 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + style, err := r.style(name) + if err != nil { + return err + } + if style.shader.RenderPass&(1<= 0 { + style.shader.Blend = 0x606 + } + for i, corner := range face.Corners { + 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] + } + if shading.Attributes&2 != 0 { + specular = &mesh.Specular[corner.Specular] + } + vertices[i].color, err = r.shade(world[i], normal, style, diffuse, specular) + if err != nil { + return err + } + if instance.opacity >= 0 { + vertices[i].color[3] = instance.opacity + } + } + r.order++ + if err := r.sectionTriangle(vertices, style.shader, pass); err != nil { + return err + } + } + } + return nil +} + +// clipTriangle 在透视除法前裁剪六个视锥面,避免近面穿越或屏外巨大坐标 +// 入参: triangle 三角面, shader 着色器, pass 绘制轮次 +// 返回: error 数值、资源或取消错误 +func (r *u3dRender) clipTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error { + var storage [2][12]u3dVertex + copy(storage[0][:], triangle[:]) + size, current := 3, 0 + planes := r.frustumPlanes() + for planeIndex, plane := range planes { + if planeIndex == 1 && r.camera.Far == 0 && !r.camera.ClipFar { + continue + } + output, next := 0, 1-current + previous := storage[current][size-1] + distance := func(v u3dVertex) float64 { + return plane[0]*v.point[0] + plane[1]*v.point[1] + plane[2]*v.point[2] + plane[3] + } + previousDistance := distance(previous) + for _, vertex := range storage[current][:size] { + d := distance(vertex) + if !finite(d) || !finite(previousDistance) { + return fmt.Errorf("invalid U3D clip distance") + } + 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 + } + if output == len(storage[next]) { + return fmt.Errorf("invalid U3D clipped polygon") + } + storage[next][output] = intersection + output++ + } + if d >= 0 { + if output == len(storage[next]) { + return fmt.Errorf("invalid U3D clipped polygon") + } + storage[next][output] = vertex + output++ + } + previous, previousDistance = vertex, d + } + if output < 3 { + return nil + } + current, size = next, output + } + for i := 1; i+1 < size; i++ { + if err := r.rasterTriangle([3]u3dVertex{storage[current][0], storage[current][i], storage[current][i+1]}, shader, pass); err != nil { + return err + } + } + return nil +} + +// rasterTriangle 使用像素中心和左上边规则光栅化,透视插值保持颜色与深度一致 +// 入参: triangle 已裁剪三角面, shader 着色器, pass 绘制轮次 +// 返回: error 数值、预算或取消错误 +func (r *u3dRender) rasterTriangle(triangle [3]u3dVertex, shader U3DShader, pass U3DViewPass) error { + var x, y, reciprocal [3]float64 + for i, v := range triangle { + reciprocal[i] = 1 + if r.camera.Perspective { + reciprocal[i] = 1 / v.point[2] + } + x[i] = r.viewport[0] + r.viewport[2]/2 + r.camera.ShiftX + v.point[0]*r.camera.ScaleX*reciprocal[i] + y[i] = r.viewport[1] + r.viewport[3]/2 + r.camera.ShiftY - v.point[1]*r.camera.ScaleY*reciprocal[i] + if !finite(x[i]) || !finite(y[i]) || !finite(reciprocal[i]) { + return fmt.Errorf("invalid U3D projected point") + } + } + area := (x[1]-x[0])*(y[2]-y[0]) - (y[1]-y[0])*(x[2]-x[0]) + if !finite(area) { + return fmt.Errorf("invalid U3D projected area") + } + if area == 0 { + return nil + } + if area < 0 { + triangle[1], triangle[2] = triangle[2], triangle[1] + x[1], x[2] = x[2], x[1] + y[1], y[2] = y[2], y[1] + reciprocal[1], reciprocal[2] = reciprocal[2], reciprocal[1] + area = -area + } + width, height := r.output.Rect.Dx(), r.output.Rect.Dy() + left := max(0, math.Ceil(min(x[0], x[1], x[2])-.5)) + right := min(float64(width-1), math.Floor(max(x[0], x[1], x[2])-.5)) + top := max(0, math.Ceil(min(y[0], y[1], y[2])-.5)) + bottom := min(float64(height-1), math.Floor(max(y[0], y[1], y[2])-.5)) + if left > right || top > bottom { + return nil + } + var inclusive [3]bool + for i := range 3 { + j := (i + 1) % 3 + inclusive[i] = y[j] < y[i] || y[j] == y[i] && x[j] > x[i] + } + for row := int(top); row <= int(bottom); row++ { + if err := r.ctx.Err(); err != nil { + return err + } + for column := int(left); column <= int(right); column++ { + if column%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + px, py := float64(column)+.5, float64(row)+.5 + var edge [3]float64 + inside := true + for i := range 3 { + j := (i + 1) % 3 + edge[i] = (x[j]-x[i])*(py-y[i]) - (y[j]-y[i])*(px-x[i]) + if edge[i] < 0 || edge[i] == 0 && !inclusive[i] { + inside = false + break + } + } + if !inside { + continue + } + w1, w2 := edge[2]/area, edge[0]/area + depth := triangle[0].point[2] + w1*(triangle[1].point[2]-triangle[0].point[2]) + w2*(triangle[2].point[2]-triangle[0].point[2]) + if r.camera.Perspective { + q := edge[1]/area*reciprocal[0] + w1*reciprocal[1] + w2*reciprocal[2] + if !finite(q) || q <= 0 { + return fmt.Errorf("invalid U3D perspective depth") + } + w1 = w1 * reciprocal[1] / q + w2 = w2 * reciprocal[2] / q + depth = 1 / q + } + pixel := row*width + column + if depth > r.frame[pixel].depth { + continue + } + var color [4]float64 + for c := range color { + color[c] = triangle[0].color[c] + w1*(triangle[1].color[c]-triangle[0].color[c]) + w2*(triangle[2].color[c]-triangle[0].color[c]) + if !finite(color[c]) { + return fmt.Errorf("invalid U3D interpolated color") + } + } + color[3] = min(1, max(0, color[3])) + if shader.Attributes&2 != 0 && !u3dAlphaTest(shader.AlphaFunction, color[3], float64(shader.AlphaReference)) { + continue + } + if pass.Attributes&1 != 0 { + 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 + } + distance := math.Hypot(math.Hypot(position[0], position[1]), position[2]) + factor := (float64(pass.FogFar) - distance) / (float64(pass.FogFar) - float64(pass.FogNear)) + if pass.FogMode != 0 { + amount := distance * math.Log(100) / float64(pass.FogFar) + if pass.FogMode == 2 { + amount *= amount + } + factor = math.Exp(-amount) + } + factor = min(1, max(0, factor)) + for c := range 3 { + color[c] = color[c]*factor + float64(pass.FogColor[c])*(1-factor) + } + } + for c := range 3 { + color[c] = min(1, max(0, color[c])) + } + if err := r.fragment(pixel, depth, color, shader.Blend); err != nil { + return err + } + } + } + return nil +} + +// fragment 直接保存不透明覆盖,透明及其他混合方式保留逐像素深度 +// 入参: pixel 像素索引, depth 深度, color 颜色, blend 混合方式 +// 返回: error 预算或取消错误 +func (r *u3dRender) fragment(pixel int, depth float64, color [4]float64, blend uint32) error { + if !finite(depth) { + return fmt.Errorf("invalid U3D fragment depth") + } + p := &r.frame[pixel] + if blend == 0x606 && color[3] == 1 || blend == 0x607 && color[3] == 0 { + p.depth = depth + p.order = r.order + copy(p.color[:], color[:3]) + return nil + } + if blend == 0x606 && color[3] == 0 || blend == 0x607 && color[3] == 1 { + return nil + } + if r.fragments >= math.MaxInt32 { + return fmt.Errorf("U3D fragment count exceeds limit") + } + chunk := r.fragments / 512 + if chunk == len(r.chunks) { + if err := r.reserve(1, 512*56+48); err != nil { + return err + } + r.chunks = append(r.chunks, make([]u3dFragment, 512)) + } + r.chunks[chunk][r.fragments%512] = u3dFragment{color: color, depth: depth, order: r.order, blend: blend, next: p.head} + p.head = int32(r.fragments) + r.fragments++ + return nil +} + +// resolveFragments 按每个像素的远近排序,正确处理相交透明面和着色器叠加 +// 返回: error 预算或取消错误 +func (r *u3dRender) resolveFragments() error { + if r.fragments == 0 { + return r.ctx.Err() + } + get := func(i int) *u3dFragment { return &r.chunks[i/512][i%512] } + for i := range r.frame { + if i%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + pixel := &r.frame[i] + r.scratch = r.scratch[:0] + for index := pixel.head; index >= 0; { + if len(r.scratch)%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + current := int(index) + fragment := get(current) + index = fragment.next + if fragment.depth > pixel.depth || fragment.depth == pixel.depth && fragment.order < pixel.order { + continue + } + if len(r.scratch) == cap(r.scratch) { + capacity := max(8, cap(r.scratch)*2) + if err := r.reserve(uint64(capacity), 8); err != nil { + return err + } + values := make([]int, len(r.scratch), capacity) + copy(values, r.scratch) + r.remaining += uint64(cap(r.scratch)) * 8 + r.scratch = values + } + r.scratch = append(r.scratch, current) + } + slices.SortFunc(r.scratch, func(a, b int) int { + x, y := get(a), get(b) + if x.depth > y.depth { + return -1 + } + if x.depth < y.depth { + return 1 + } + if x.order < y.order { + return -1 + } + if x.order > y.order { + return 1 + } + return 0 + }) + for layer, index := range r.scratch { + if layer%1024 == 0 { + if err := r.ctx.Err(); err != nil { + return err + } + } + fragment := get(index) + alpha := fragment.color[3] + if fragment.blend == 0x607 { + alpha = 1 - alpha + } + for c := range 3 { + switch fragment.blend { + case 0x604: + pixel.color[c] += fragment.color[c] + case 0x605: + pixel.color[c] *= fragment.color[c] + default: + pixel.color[c] = fragment.color[c]*alpha + pixel.color[c]*(1-alpha) + } + pixel.color[c] = min(1, max(0, pixel.color[c])) + } + } + } + return r.ctx.Err() +} diff --git a/ofdgo_u3d_view.go b/ofdgo_u3d_view.go new file mode 100644 index 0000000..d23c9ff --- /dev/null +++ b/ofdgo_u3d_view.go @@ -0,0 +1,131 @@ +// 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" +) + +// U3DView 保存视图节点的投影、裁剪、视口及叠加层参数 +type U3DView struct { + Attributes uint32 + Near, Far float32 + Projection float32 + ProjectionVector [3]float32 + Viewport [4]float32 + Backdrops, Overlays []U3DViewLayer +} + +// U3DViewLayer 保存背景或覆盖纹理,注册点使用纹理像素,旋转使用弧度 +type U3DViewLayer struct { + Texture string + Blend, Rotation float32 + Location [2]float32 + Registration [2]int32 + Scale [2]float32 +} + +// U3DViewResource 保存视图的绘制轮次,各轮独立指定根节点及雾效 +type U3DViewResource struct { + Name string + Passes []U3DViewPass +} + +// U3DViewPass 保存单轮根节点与雾效,Attributes最低位启用雾效 +type U3DViewPass struct { + Root string + Attributes, FogMode uint32 + FogColor [4]float32 + FogNear, FogFar float32 +} + +// view 按ECMA-363第9.5.4节读取视图节点的可变投影字段 +// 入参: r 字段读取器 +// 返回: *U3DView 视图, error 结构或预算错误 +func (d *u3dDecoder) view(r *u3dValues) (*U3DView, error) { + view := &U3DView{Attributes: r.u32(), Near: r.f32(), Far: r.f32()} + if view.Attributes & ^uint32(7) != 0 { + return nil, fmt.Errorf("invalid U3D view attributes") + } + if view.Attributes&6 < 4 { + view.Projection = r.f32() + } else { + for i := range view.ProjectionVector { + view.ProjectionVector[i] = r.f32() + } + } + for i := range view.Viewport { + view.Viewport[i] = r.f32() + } + for _, layers := range []*[]U3DViewLayer{&view.Backdrops, &view.Overlays} { + count := r.u32() + if uint64(count) > uint64(len(r.data)-r.pos)/34 { + return nil, io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 64); err != nil { + return nil, err + } + *layers = make([]U3DViewLayer, int(count)) + for i := range *layers { + if err := d.ctx.Err(); err != nil { + return nil, err + } + layer := &(*layers)[i] + layer.Texture = r.text() + layer.Blend, layer.Rotation = r.f32(), r.f32() + for j := range layer.Location { + layer.Location[j] = r.f32() + } + for j := range layer.Registration { + layer.Registration[j] = int32(r.u32()) + } + for j := range layer.Scale { + layer.Scale[j] = r.f32() + } + } + } + return view, r.err +} + +// viewResource 按ECMA-363第9.8.2节读取绘制轮次与雾效 +// 入参: r 字段读取器, name 资源名 +// 返回: error 结构或预算错误 +func (d *u3dDecoder) viewResource(r *u3dValues, name string) error { + count := r.u32() + if uint64(count) > uint64(len(r.data)-r.pos)/34 { + return io.ErrUnexpectedEOF + } + if err := d.reserve(uint64(count), 48); err != nil { + return err + } + view := U3DViewResource{Name: name, Passes: make([]U3DViewPass, int(count))} + for i := range view.Passes { + if err := d.ctx.Err(); err != nil { + return err + } + pass := &view.Passes[i] + pass.Root, pass.Attributes, pass.FogMode = r.text(), r.u32(), r.u32() + if pass.Attributes > 1 || pass.FogMode > 2 { + return fmt.Errorf("invalid U3D view fog parameters") + } + for j := range pass.FogColor { + pass.FogColor[j] = r.f32() + } + pass.FogNear, pass.FogFar = r.f32(), r.f32() + } + d.model.Views = append(d.model.Views, view) + return r.err +}