Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ on:
description: 'Tag name for the release (e.g., v1.0.0)'
required: true
type: string
prerelease:
description: 'Mark as pre-release'
required: false
type: boolean
default: false

jobs:
build:
Expand Down Expand Up @@ -122,5 +127,6 @@ jobs:
tag_name: ${{ github.event.inputs.tag }}
files: release/*
generate_release_notes: true
prerelease: ${{ github.event.inputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 changes: 18 additions & 0 deletions characters/maz_test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"bodyCharacteristic": "Default.48",
"underwear": "Bra.Black",
"face": "Face_Scar",
"ears": "Elf_Ears_Large_Down",
"mouth": "Mouth_Tiny",
"haircut": "Long.White",
"eyebrows": "Plucked.White",
"eyes": "Square_Eyes.Orange",
"pants": "LongDungarees.Black.LongDungareesSlacks",
"overpants": "KneePads.Iron_Black",
"undertop": "Top_Wasteland_Marauder.Black",
"overtop": "TrenchCoat.Orange",
"shoes": "ScavenverLeatherBoots.Brown",
"headAccessory": "WitchHat.Orange",
"earAccessory": "DoubleEarrings.Iron_Black.Right",
"gloves": "LongGloves_Popstar.Black"
}
115 changes: 98 additions & 17 deletions pkg/blockymodel/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,113 @@ func Save(model *BlockyModel, path string) error {
return nil
}

// Clone creates a deep copy of a BlockyModel via JSON marshal/unmarshal
// Clone creates a deep copy of a BlockyModel
func Clone(model *BlockyModel) (*BlockyModel, error) {
data, err := json.Marshal(model)
if err != nil {
return nil, fmt.Errorf("failed to marshal model for cloning: %w", err)
cloned := &BlockyModel{
LOD: model.LOD,
}

var cloned BlockyModel
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, fmt.Errorf("failed to unmarshal model for cloning: %w", err)
if len(model.Nodes) > 0 {
cloned.Nodes = make([]Node, len(model.Nodes))
for i := range model.Nodes {
cloned.Nodes[i] = cloneNode(model.Nodes[i])
}
}
return cloned, nil
}

// CloneNode creates a deep copy of a single Node
func CloneNode(node *Node) (*Node, error) {
cloned := cloneNode(*node)
return &cloned, nil
}

// CloneNode creates a deep copy of a single Node via JSON marshal/unmarshal
func CloneNode(node *Node) (*Node, error) {
data, err := json.Marshal(node)
if err != nil {
return nil, fmt.Errorf("failed to marshal node for cloning: %w", err)
func cloneNode(node Node) Node {
cloned := Node{
ID: node.ID,
Name: node.Name,
}
if node.Position != nil {
p := *node.Position
cloned.Position = &p
}
if node.Orientation != nil {
o := *node.Orientation
cloned.Orientation = &o
}
if node.Shape != nil {
cloned.Shape = cloneShape(node.Shape)
}
if len(node.Children) > 0 {
cloned.Children = make([]Node, len(node.Children))
for i := range node.Children {
cloned.Children[i] = cloneNode(node.Children[i])
}
}
return cloned
}

var cloned Node
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, fmt.Errorf("failed to unmarshal node for cloning: %w", err)
func cloneShape(shape *Shape) *Shape {
cloned := &Shape{
Type: shape.Type,
UnwrapMode: shape.UnwrapMode,
Visible: shape.Visible,
DoubleSided: shape.DoubleSided,
ShadingMode: shape.ShadingMode,
}
if shape.Offset != nil {
o := *shape.Offset
cloned.Offset = &o
}
if shape.Stretch != nil {
s := *shape.Stretch
cloned.Stretch = &s
}
if shape.Visible != nil {
v := *shape.Visible
cloned.Visible = &v
}
if shape.DoubleSided != nil {
d := *shape.DoubleSided
cloned.DoubleSided = &d
}
if shape.Settings != nil {
cloned.Settings = cloneMap(shape.Settings)
}
if shape.TextureLayout != nil {
cloned.TextureLayout = make(map[string]TextureFace, len(shape.TextureLayout))
for k, v := range shape.TextureLayout {
cloned.TextureLayout[k] = v
}
}
return cloned
}

return &cloned, nil
func cloneMap(m map[string]interface{}) map[string]interface{} {
cloned := make(map[string]interface{}, len(m))
for k, v := range m {
switch val := v.(type) {
case map[string]interface{}:
cloned[k] = cloneMap(val)
case []interface{}:
cloned[k] = cloneSlice(val)
default:
cloned[k] = v
}
}
return cloned
}

func cloneSlice(s []interface{}) []interface{} {
cloned := make([]interface{}, len(s))
for i, v := range s {
switch val := v.(type) {
case map[string]interface{}:
cloned[i] = cloneMap(val)
case []interface{}:
cloned[i] = cloneSlice(val)
default:
cloned[i] = v
}
}
return cloned
}
17 changes: 9 additions & 8 deletions pkg/export/glb.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,11 @@ func (e *GLBExporter) createBoxMesh(node *blockymodel.Node, materialIdx uint32,
flipY := stretchY < 0
flipZ := stretchZ < 0

var positions [][3]float32
var normals [][3]float32
var uvs [][2]float32
var indices []uint16
// Pre-allocate: max 6 faces × 4 verts = 24 verts, 6 faces × 6 indices = 36
positions := make([][3]float32, 0, 24)
normals := make([][3]float32, 0, 24)
uvs := make([][2]float32, 0, 24)
indices := make([]uint16, 0, 36)

// Blockbench face order: east, west, up, down, south, north
// This maps to: X+, X-, Y+, Y-, Z+, Z-
Expand Down Expand Up @@ -697,11 +698,11 @@ func (e *GLBExporter) createQuadMesh(node *blockymodel.Node, materialIdx uint32,
}
}

var normalsArr [][3]float32
var uvsArr [][2]float32
normalsArr := make([][3]float32, 4)
uvsArr := make([][2]float32, 4)
for i := 0; i < 4; i++ {
normalsArr = append(normalsArr, flippedNormal)
uvsArr = append(uvsArr, uvs[i])
normalsArr[i] = flippedNormal
uvsArr[i] = uvs[i]
}

// Triangle indices - reverse winding if odd number of flips
Expand Down
54 changes: 34 additions & 20 deletions pkg/merger/merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (

// Merger handles merging accessories into a base model
type Merger struct {
base *blockymodel.BlockyModel
nextID int
base *blockymodel.BlockyModel
nextID int
nodeIndex map[string]*blockymodel.Node // name -> node for O(1) lookups
// Track which accessory each merged node ID came from
NodeSources map[string]string // node ID -> accessory ID
}
Expand All @@ -26,11 +27,33 @@ func New(base *blockymodel.BlockyModel) (*Merger, error) {
// Find the highest existing ID to use for new nodes
maxID := findMaxID(cloned.Nodes)

return &Merger{
m := &Merger{
base: cloned,
nextID: maxID + 1,
nodeIndex: make(map[string]*blockymodel.Node),
NodeSources: make(map[string]string),
}, nil
}
m.buildNodeIndex(cloned.Nodes)
return m, nil
}

// buildNodeIndex walks the node tree and indexes nodes by name
func (m *Merger) buildNodeIndex(nodes []blockymodel.Node) {
for i := range nodes {
m.nodeIndex[nodes[i].Name] = &nodes[i]
m.buildNodeIndex(nodes[i].Children)
}
}

// reindexChildren refreshes index pointers for all children of a node.
// Must be called after appending to node.Children, since append may
// reallocate the slice and invalidate pointers to existing siblings.
func (m *Merger) reindexChildren(node *blockymodel.Node) {
for i := range node.Children {
child := &node.Children[i]
m.nodeIndex[child.Name] = child
m.reindexChildren(child)
}
}

// Merge integrates an accessory into the base model
Expand All @@ -52,7 +75,7 @@ func (m *Merger) Result() *blockymodel.BlockyModel {
// mergeNode processes a single accessory node
func (m *Merger) mergeNode(accessoryNode *blockymodel.Node, accessoryID string) error {
// Check if this node matches a bone in the base model (either skeleton ref or by name)
baseNode := findNodeByName(m.base.Nodes, accessoryNode.Name)
baseNode := m.nodeIndex[accessoryNode.Name]

if accessoryNode.IsSkeletonReference() || (baseNode != nil && accessoryNode.Shape != nil && accessoryNode.Shape.Type == "none") {
// This is an attachment point - attach children to base model
Expand All @@ -78,6 +101,9 @@ func (m *Merger) mergeNode(accessoryNode *blockymodel.Node, accessoryID string)
cloned.Children = m.filterSkeletonRefs(cloned.Children, accessoryID)
m.reIDNode(cloned, accessoryID)
baseNode.Children = append(baseNode.Children, *cloned)
// Re-index all children — append may have reallocated the slice,
// invalidating pointers to existing siblings
m.reindexChildren(baseNode)
} else {
// Recurse into skeleton reference children
if err := m.mergeNode(child, accessoryID); err != nil {
Expand All @@ -103,7 +129,7 @@ func (m *Merger) filterSkeletonRefs(children []blockymodel.Node, accessoryID str
child := &children[i]
if child.IsSkeletonReference() || m.isAttachmentPoint(child) {
// This is a skeleton ref - process its children but don't add the ref itself
baseNode := findNodeByName(m.base.Nodes, child.Name)
baseNode := m.nodeIndex[child.Name]
if baseNode != nil {
// Recursively process skeleton ref's children and attach them to base
for j := range child.Children {
Expand All @@ -114,6 +140,7 @@ func (m *Merger) filterSkeletonRefs(children []blockymodel.Node, accessoryID str
cloned.Children = m.filterSkeletonRefs(cloned.Children, accessoryID)
m.reIDNode(cloned, accessoryID)
baseNode.Children = append(baseNode.Children, *cloned)
m.reindexChildren(baseNode)
}
} else {
// Recurse into nested skeleton refs
Expand Down Expand Up @@ -141,7 +168,7 @@ func (m *Merger) isAttachmentPoint(node *blockymodel.Node) bool {
// The matching base node must also be a non-geometry node (type "none") to avoid
// false matches against geometry nodes that happen to share the same name.
if node.Shape != nil && node.Shape.Type == "none" {
baseNode := findNodeByName(m.base.Nodes, node.Name)
baseNode := m.nodeIndex[node.Name]
if baseNode != nil && baseNode.Shape != nil && baseNode.Shape.Type == "none" {
return true
}
Expand All @@ -159,19 +186,6 @@ func (m *Merger) reIDNode(node *blockymodel.Node, accessoryID string) {
}
}

// findNodeByName recursively searches for a node with the given name
func findNodeByName(nodes []blockymodel.Node, name string) *blockymodel.Node {
for i := range nodes {
if nodes[i].Name == name {
return &nodes[i]
}
if found := findNodeByName(nodes[i].Children, name); found != nil {
return found
}
}
return nil
}

// findMaxID recursively finds the maximum numeric ID in the node tree
func findMaxID(nodes []blockymodel.Node) int {
maxID := 0
Expand Down
Loading
Loading