internal/sh3d/sh3d.go
Rama main · réplica de lectura del repositorio original.
// Package sh3d reads plan geometry out of a SweetHome3D file.
//
// A .sh3d file is a ZIP archive. Since SweetHome3D 5.3 it carries a Home.xml
// entry next to the Java-serialised Home, and that XML is the only part read
// here: walls, rooms, levels and furniture as plain numbers. Models, textures
// and everything 3D are ignored. Coordinates are centimetres with y pointing
// down, angles are radians clockwise.
package sh3d
import (
"archive/zip"
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"math"
"os"
"sort"
"strconv"
)
// MaxXML caps Home.xml once decompressed. A real flat is tens of kilobytes; the
// cap is what stops a small archive from inflating into gigabytes.
const MaxXML = 16 << 20
type Point struct{ X, Y float64 }
type Level struct {
ID string
Name string
Elevation float64
ElevationIndex int
}
type Wall struct {
Level string
XStart, YStart, XEnd, YEnd float64
Thickness float64
}
type Room struct {
Level string
Name string
Points []Point
NameXOffset, NameYOffset float64
}
// Piece is any piece of furniture, doors and windows included. X and Y are the
// centre of its footprint, Width runs along the angle and Depth across it.
type Piece struct {
Level string
Name string
X, Y, Width, Depth float64
Angle, Elevation float64
DoorOrWindow bool
WallThickness float64 // fraction of Depth that sits inside the wall
WallDistance float64 // fraction of Depth in front of the wall
}
type Home struct {
Levels []Level
Walls []Wall
Rooms []Room
Furniture []Piece
}
// Open reads a .sh3d file from disk.
func Open(path string) (*Home, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, err
}
return Read(f, st.Size())
}
// Read decodes a .sh3d archive. It takes a ReaderAt so an upload can be checked
// before it replaces anything on disk.
func Read(r io.ReaderAt, size int64) (*Home, error) {
zr, err := zip.NewReader(r, size)
if err != nil {
return nil, errors.New("not a SweetHome3D file: it is not a ZIP archive")
}
for _, f := range zr.File {
if f.Name != "Home.xml" {
continue
}
if f.UncompressedSize64 > MaxXML {
return nil, fmt.Errorf("Home.xml is %d bytes, more than the %d allowed", f.UncompressedSize64, MaxXML)
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("opening Home.xml: %w", err)
}
defer rc.Close()
// The header can lie about the size, so the limit is enforced on the
// bytes actually inflated as well.
b, err := io.ReadAll(io.LimitReader(rc, MaxXML+1))
if err != nil {
return nil, fmt.Errorf("reading Home.xml: %w", err)
}
if len(b) > MaxXML {
return nil, fmt.Errorf("Home.xml is larger than the %d bytes allowed", MaxXML)
}
return decode(b)
}
return nil, errors.New("the file has no Home.xml; re-save it with SweetHome3D 5.3 or newer")
}
var furnitureTags = map[string]bool{
"pieceOfFurniture": true,
"doorOrWindow": true,
"shelfUnit": true,
"light": true,
}
func decode(b []byte) (*Home, error) {
h := &Home{}
dec := xml.NewDecoder(bytes.NewReader(b))
var (
room *Room
groups []string // level of each open furnitureGroup
sawTop bool
)
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("parsing Home.xml: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
a := attrs(t.Attr)
if !sawTop {
if t.Name.Local != "home" {
return nil, fmt.Errorf("Home.xml starts with <%s>, not <home>", t.Name.Local)
}
sawTop = true
continue
}
// A group's children often carry no level of their own; they sit
// on the group's.
level := a.str("level")
if level == "" && len(groups) > 0 {
level = groups[len(groups)-1]
}
switch name := t.Name.Local; {
case name == "level":
h.Levels = append(h.Levels, Level{
ID: a.str("id"), Name: a.str("name"),
Elevation: a.num("elevation"), ElevationIndex: int(a.num("elevationIndex")),
})
case name == "wall":
h.Walls = append(h.Walls, Wall{
Level: level,
XStart: a.num("xStart"), YStart: a.num("yStart"),
XEnd: a.num("xEnd"), YEnd: a.num("yEnd"),
Thickness: a.num("thickness"),
})
case name == "room":
room = &Room{
Level: level, Name: a.str("name"),
NameXOffset: a.num("nameXOffset"), NameYOffset: a.num("nameYOffset"),
}
case name == "point" && room != nil:
room.Points = append(room.Points, Point{a.num("x"), a.num("y")})
case name == "furnitureGroup":
groups = append(groups, level)
case furnitureTags[name]:
if a.str("visible") == "false" {
continue
}
p := Piece{
Level: level, Name: a.str("name"),
X: a.num("x"), Y: a.num("y"),
Width: a.num("width"), Depth: a.num("depth"),
Angle: a.num("angle"), Elevation: a.num("elevation"),
DoorOrWindow: name == "doorOrWindow",
WallThickness: 1,
}
// SweetHome3D omits both when they are the defaults: the whole
// depth is wall, none of it in front.
if a.has("wallThickness") {
p.WallThickness = a.num("wallThickness")
}
p.WallDistance = a.num("wallDistance")
h.Furniture = append(h.Furniture, p)
}
if a.err != nil {
return nil, fmt.Errorf("<%s>: %w", t.Name.Local, a.err)
}
case xml.EndElement:
switch t.Name.Local {
case "room":
if room != nil {
h.Rooms = append(h.Rooms, *room)
room = nil
}
case "furnitureGroup":
if len(groups) > 0 {
groups = groups[:len(groups)-1]
}
}
}
}
if !sawTop {
return nil, errors.New("Home.xml is empty")
}
return h, nil
}
// FirstLevel returns only what sits on the lowest level, the one SweetHome3D
// opens on. A home without levels is returned whole.
func (h *Home) FirstLevel() *Home {
if len(h.Levels) == 0 {
return h
}
levels := append([]Level(nil), h.Levels...)
sort.SliceStable(levels, func(i, j int) bool {
if levels[i].Elevation != levels[j].Elevation {
return levels[i].Elevation < levels[j].Elevation
}
return levels[i].ElevationIndex < levels[j].ElevationIndex
})
id := levels[0].ID
out := &Home{Levels: []Level{levels[0]}}
for _, w := range h.Walls {
if w.Level == id {
out.Walls = append(out.Walls, w)
}
}
for _, r := range h.Rooms {
if r.Level == id {
out.Rooms = append(out.Rooms, r)
}
}
for _, p := range h.Furniture {
if p.Level == id {
out.Furniture = append(out.Furniture, p)
}
}
return out
}
type attrMap struct {
m map[string]string
err error
}
func attrs(list []xml.Attr) *attrMap {
a := &attrMap{m: make(map[string]string, len(list))}
for _, at := range list {
a.m[at.Name.Local] = at.Value
}
return a
}
func (a *attrMap) str(k string) string { return a.m[k] }
func (a *attrMap) has(k string) bool { _, ok := a.m[k]; return ok }
// num returns 0 for a missing attribute, which is SweetHome3D's own default for
// every number it omits. A malformed or non-finite one is an error rather than a
// wall drawn to infinity.
func (a *attrMap) num(k string) float64 {
s, ok := a.m[k]
if !ok {
return 0
}
v, err := strconv.ParseFloat(s, 64)
if err == nil && (math.IsNaN(v) || math.IsInf(v, 0)) {
err = errors.New("not a finite number")
}
if err != nil {
if a.err == nil {
a.err = fmt.Errorf("attribute %s=%q: %w", k, s, err)
}
return 0
}
return v
}