@soonspacejs/plugin-soonbuilder-loader 2.16.9 → 2.16.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/index.esm.js +51 -51
- package/package.json +2 -2
- package/test/resource-url.test.mjs +180 -0
package/README.md
CHANGED
|
@@ -3,3 +3,15 @@
|
|
|
3
3
|
> Soonbuilder-loader plugin for SoonSpace.js
|
|
4
4
|
|
|
5
5
|
Document: [http://www.xwbuilders.com:8800/plugin/soonbuilder-loader.html](http://www.xwbuilders.com:8800/plugin/soonbuilder-loader.html)
|
|
6
|
+
|
|
7
|
+
清单中的资源文件名按原始路径读取,加载时逐段进行 URL 编码,保留目录分隔符。
|
|
8
|
+
楼层模型、附属构件和场景 XML 文件名中的 `#`、`?`、`&`、`+`、`%` 均作为文件名的一部分发送。
|
|
9
|
+
|
|
10
|
+
资源 URL 回归验证(在仓库根目录执行,无需额外安装测试框架):
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm run build:package -- plugin-soonbuilder-loader
|
|
14
|
+
node --test packages/@soonspacejs/plugin-soonbuilder-loader/test/resource-url.test.mjs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
用例通过本地 HTTP 服务模拟签名接口和存储重定向,验证两次请求收到的对象路径均完整。
|
package/dist/index.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const y = ["FACILITY", "DOOR", "WINDOW"];
|
|
2
|
-
class
|
|
2
|
+
class $ {
|
|
3
3
|
constructor(t) {
|
|
4
4
|
this.ssp = t, this.treeData = [], this.modelData = [];
|
|
5
5
|
}
|
|
@@ -7,11 +7,11 @@ class v {
|
|
|
7
7
|
sceneInfo;
|
|
8
8
|
treeData;
|
|
9
9
|
modelData;
|
|
10
|
-
async load(t,
|
|
11
|
-
const { xmlToJson:
|
|
12
|
-
this.fileInfo = await
|
|
10
|
+
async load(t, i = !1) {
|
|
11
|
+
const { xmlToJson: n, isArray: o, isObject: e } = this.ssp.utils, h = i ? `${t}/FileInfo.xml` : t;
|
|
12
|
+
this.fileInfo = await n(h);
|
|
13
13
|
const x = this._getFileExtensionFc(this.fileInfo), p = h.split("FileInfo.xml")[0], D = this.fileInfo.folder._attributes.name;
|
|
14
|
-
this.sceneInfo = await
|
|
14
|
+
this.sceneInfo = await n(this._pathModeChange(p, `${D}.xml`));
|
|
15
15
|
const {
|
|
16
16
|
Project: {
|
|
17
17
|
Building: { Floors: c, ObjectHierarchy: u }
|
|
@@ -25,86 +25,86 @@ class v {
|
|
|
25
25
|
userData: l
|
|
26
26
|
};
|
|
27
27
|
this._pushModelData(f);
|
|
28
|
-
const
|
|
28
|
+
const I = await this.ssp.loadModelToGroup(
|
|
29
29
|
{
|
|
30
30
|
id: `${f.id}_group`
|
|
31
31
|
},
|
|
32
32
|
[f]
|
|
33
33
|
);
|
|
34
|
-
if (!
|
|
35
|
-
let
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
({ _attributes: { id:
|
|
34
|
+
if (!I) return;
|
|
35
|
+
let r = null;
|
|
36
|
+
if (o(u.Entity))
|
|
37
|
+
r = u.Entity.find(
|
|
38
|
+
({ _attributes: { id: s, type: d } }) => d === "Floor" && s === _
|
|
39
39
|
);
|
|
40
|
-
else if (
|
|
41
|
-
const { id:
|
|
42
|
-
|
|
40
|
+
else if (e(u.Entity)) {
|
|
41
|
+
const { id: s, type: d } = u.Entity._attributes;
|
|
42
|
+
s === _ && d === "Floor" && (r = u.Entity);
|
|
43
43
|
}
|
|
44
|
-
if (!
|
|
44
|
+
if (!r) return;
|
|
45
45
|
let a = [];
|
|
46
|
-
|
|
47
|
-
({ _attributes: { type:
|
|
48
|
-
) : r
|
|
49
|
-
for (let
|
|
50
|
-
const { id: d, longname:
|
|
51
|
-
...a[
|
|
52
|
-
...a[
|
|
46
|
+
o(r.Entity) ? a = r.Entity.filter(
|
|
47
|
+
({ _attributes: { type: s } }) => y.includes(s)
|
|
48
|
+
) : e(r.Entity) && y.includes(r.Entity._attributes.type) && (a = [r.Entity]);
|
|
49
|
+
for (let s = 0; s < a.length; s++) {
|
|
50
|
+
const { id: d, longname: E, visible: O, position: T, eular: w, scale: C } = {
|
|
51
|
+
...a[s]._attributes,
|
|
52
|
+
...a[s].Properties?._attributes ?? {}
|
|
53
53
|
}, b = {
|
|
54
54
|
id: d,
|
|
55
|
-
name:
|
|
56
|
-
url: this._pathModeChange(p, `/${x(
|
|
55
|
+
name: E,
|
|
56
|
+
url: this._pathModeChange(p, `/${x(E)}`),
|
|
57
57
|
visible: this._text2Bool(O),
|
|
58
58
|
position: this._text2IVector3(T),
|
|
59
|
-
rotation: this._text2IVector3(
|
|
60
|
-
scale: this._text2IVector3(
|
|
61
|
-
userData: a[
|
|
62
|
-
},
|
|
63
|
-
|
|
59
|
+
rotation: this._text2IVector3(w),
|
|
60
|
+
scale: this._text2IVector3(C),
|
|
61
|
+
userData: a[s]
|
|
62
|
+
}, F = await this.ssp.loadModel(b);
|
|
63
|
+
F && (this.ssp.addObject(F, I), this._pushModelData(b), f.children?.push(b));
|
|
64
64
|
}
|
|
65
65
|
this.treeData.push(f);
|
|
66
66
|
};
|
|
67
|
-
if (
|
|
67
|
+
if (o(c.Floor))
|
|
68
68
|
for (let l = 0; l < c.Floor.length; l++)
|
|
69
69
|
await m(c.Floor[l]);
|
|
70
|
-
else
|
|
70
|
+
else e(c.Floor) && await m(c.Floor);
|
|
71
71
|
}
|
|
72
72
|
_pushModelData(t) {
|
|
73
|
-
this.modelData.find(({ url:
|
|
73
|
+
this.modelData.find(({ url: n }) => n === t.url) || this.modelData.push(t);
|
|
74
74
|
}
|
|
75
75
|
_text2IVector3(t) {
|
|
76
76
|
if (!t) return;
|
|
77
|
-
const
|
|
77
|
+
const i = t.split(", ");
|
|
78
78
|
return {
|
|
79
|
-
x: Number(
|
|
80
|
-
y: Number(
|
|
81
|
-
z: Number(
|
|
79
|
+
x: Number(i[0]),
|
|
80
|
+
y: Number(i[1]),
|
|
81
|
+
z: Number(i[2])
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
_text2Bool(t) {
|
|
85
85
|
if (t !== void 0)
|
|
86
86
|
return t === "True";
|
|
87
87
|
}
|
|
88
|
-
_pathModeChange(t,
|
|
89
|
-
const i = t +
|
|
90
|
-
if (
|
|
91
|
-
const
|
|
92
|
-
return
|
|
88
|
+
_pathModeChange(t, i) {
|
|
89
|
+
const n = i.replace(/\\/g, "/").split("/").map((e) => encodeURIComponent(e)).join("/"), o = t + n;
|
|
90
|
+
if (o.includes("://")) {
|
|
91
|
+
const e = o.split("://");
|
|
92
|
+
return e[0] + "://" + e[1].replace("\\", "/").replace("./", "/").replace("//", "/");
|
|
93
93
|
} else
|
|
94
|
-
return
|
|
94
|
+
return o.replace("\\", "/").replace("./", "/").replace("//", "/");
|
|
95
95
|
}
|
|
96
96
|
_getFileExtensionFc(t) {
|
|
97
|
-
const
|
|
98
|
-
let
|
|
99
|
-
return
|
|
100
|
-
const { Extension:
|
|
101
|
-
return
|
|
102
|
-
}))), (
|
|
103
|
-
const
|
|
104
|
-
return `${
|
|
97
|
+
const i = t?.folder?.file;
|
|
98
|
+
let n = [];
|
|
99
|
+
return i && Array.isArray(i) && (n = i.filter(((o) => {
|
|
100
|
+
const { Extension: e } = o._attributes;
|
|
101
|
+
return e === ".sbm" || e === ".sbmx";
|
|
102
|
+
}))), (o) => {
|
|
103
|
+
const e = n.find((h) => h?._attributes?.name.startsWith(o))?._attributes;
|
|
104
|
+
return `${o}${e?.Extension || ".sbm"}`;
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
export {
|
|
109
|
-
|
|
109
|
+
$ as default
|
|
110
110
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soonspacejs/plugin-soonbuilder-loader",
|
|
3
3
|
"pluginName": "SoonbuilderLoaderPlugin",
|
|
4
|
-
"version": "2.16.
|
|
4
|
+
"version": "2.16.11",
|
|
5
5
|
"description": "soonbuilder-loader plugin for SoonSpace.js",
|
|
6
6
|
"main": "dist/index.esm.js",
|
|
7
7
|
"module": "dist/index.esm.js",
|
|
@@ -15,6 +15,6 @@
|
|
|
15
15
|
"license": "UNLICENSED",
|
|
16
16
|
"gitHead": "4a87dd85050651088e11c3da777c49fab3db7e5d",
|
|
17
17
|
"peerDependencies": {
|
|
18
|
-
"soonspacejs": "2.16.
|
|
18
|
+
"soonspacejs": "2.16.11"
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { once, } from 'node:events'
|
|
3
|
+
import { createServer, } from 'node:http'
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
|
|
6
|
+
import SoonbuilderLoaderPlugin from '../dist/index.esm.js'
|
|
7
|
+
|
|
8
|
+
const encodePath = ( path ) => path.split( '/' ).map( encodeURIComponent ).join( '/' )
|
|
9
|
+
|
|
10
|
+
async function loadProject ( t, { directory, sceneName, floors, component, query, } ) {
|
|
11
|
+
|
|
12
|
+
const resources = new Map( [
|
|
13
|
+
[ `${directory}FileInfo.xml`, {
|
|
14
|
+
folder: {
|
|
15
|
+
_attributes: { name: sceneName, },
|
|
16
|
+
file: [ { _attributes: { name: `${component}.sbmx`, Extension: '.sbmx', }, } ],
|
|
17
|
+
},
|
|
18
|
+
} ],
|
|
19
|
+
[ `${directory}${sceneName}.xml`, {
|
|
20
|
+
Project: {
|
|
21
|
+
Building: {
|
|
22
|
+
Floors: {
|
|
23
|
+
Floor: floors.map( ( name, index ) => ( {
|
|
24
|
+
_attributes: { id: `${index}`, name: `Floor ${index}`, },
|
|
25
|
+
FileSource: { _attributes: { name, }, },
|
|
26
|
+
} ) ),
|
|
27
|
+
},
|
|
28
|
+
ObjectHierarchy: {
|
|
29
|
+
Entity: floors.map( ( _, index ) => ( {
|
|
30
|
+
_attributes: { id: `${index}`, type: 'Floor', },
|
|
31
|
+
Entity: [ {
|
|
32
|
+
_attributes: {
|
|
33
|
+
id: `${index}-component`,
|
|
34
|
+
type: 'FACILITY',
|
|
35
|
+
longname: component,
|
|
36
|
+
position: '0, 0, 0',
|
|
37
|
+
eular: '0, 0, 0',
|
|
38
|
+
scale: '1, 1, 1',
|
|
39
|
+
},
|
|
40
|
+
} ],
|
|
41
|
+
} ) ),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} ],
|
|
46
|
+
...floors.map( ( name ) => [ `${directory}${name.replace( /\\/g, '/' )}`, {} ] ),
|
|
47
|
+
[ `${directory}${component}.sbmx`, {} ]
|
|
48
|
+
] )
|
|
49
|
+
const signingKeys = []
|
|
50
|
+
const storageKeys = []
|
|
51
|
+
const server = createServer( ( req, res ) => {
|
|
52
|
+
|
|
53
|
+
const url = new URL( req.url, 'http://localhost' )
|
|
54
|
+
const isStorage = url.pathname.startsWith( '/obs/' )
|
|
55
|
+
const key = isStorage ?
|
|
56
|
+
decodeURIComponent( url.pathname.slice( '/obs/'.length ) ) :
|
|
57
|
+
query ?
|
|
58
|
+
url.searchParams.get( 'path' ) :
|
|
59
|
+
decodeURIComponent( url.pathname.slice( '/tempPath/'.length ) )
|
|
60
|
+
|
|
61
|
+
;( isStorage ? storageKeys : signingKeys ).push( key )
|
|
62
|
+
|
|
63
|
+
if ( !resources.has( key ) ) {
|
|
64
|
+
|
|
65
|
+
res.writeHead( 404 )
|
|
66
|
+
res.end( `NoSuchKey: ${key}` )
|
|
67
|
+
|
|
68
|
+
} else if ( !isStorage ) {
|
|
69
|
+
|
|
70
|
+
// Simulate the signing gateway redirecting to the complete object key.
|
|
71
|
+
res.writeHead( 302, {
|
|
72
|
+
Location: `/obs/${encodePath( key )}?X-Amz-Signature=test`,
|
|
73
|
+
} )
|
|
74
|
+
res.end()
|
|
75
|
+
|
|
76
|
+
} else {
|
|
77
|
+
|
|
78
|
+
res.setHeader( 'Content-Type', 'application/json' )
|
|
79
|
+
res.end( JSON.stringify( resources.get( key ) ) )
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
} )
|
|
84
|
+
|
|
85
|
+
t.after( () => new Promise( ( resolve ) => {
|
|
86
|
+
|
|
87
|
+
server.close( resolve )
|
|
88
|
+
server.closeAllConnections()
|
|
89
|
+
|
|
90
|
+
} ) )
|
|
91
|
+
server.listen( 0, '127.0.0.1' )
|
|
92
|
+
await once( server, 'listening' )
|
|
93
|
+
|
|
94
|
+
async function readResource ( url ) {
|
|
95
|
+
|
|
96
|
+
const response = await fetch( url )
|
|
97
|
+
|
|
98
|
+
assert.equal( response.status, 200, `Resource request failed: ${url}` )
|
|
99
|
+
return response.json()
|
|
100
|
+
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const ssp = {
|
|
104
|
+
utils: {
|
|
105
|
+
xmlToJson: readResource,
|
|
106
|
+
isArray: Array.isArray,
|
|
107
|
+
isObject: ( value ) => value !== null && typeof value === 'object',
|
|
108
|
+
},
|
|
109
|
+
async loadModelToGroup ( group, models ) {
|
|
110
|
+
|
|
111
|
+
await Promise.all( models.map( ( { url, } ) => readResource( url ) ) )
|
|
112
|
+
return group
|
|
113
|
+
|
|
114
|
+
},
|
|
115
|
+
async loadModel ( model ) {
|
|
116
|
+
|
|
117
|
+
await readResource( model.url )
|
|
118
|
+
return model
|
|
119
|
+
|
|
120
|
+
},
|
|
121
|
+
addObject () {},
|
|
122
|
+
}
|
|
123
|
+
const loader = new SoonbuilderLoaderPlugin( ssp )
|
|
124
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
125
|
+
const prefix = query ? '/tempUrl?path=' : '/tempPath/'
|
|
126
|
+
|
|
127
|
+
await loader.load( `${origin}${prefix}${encodePath( directory )}FileInfo.xml` )
|
|
128
|
+
|
|
129
|
+
const expectedKeys = [
|
|
130
|
+
`${directory}FileInfo.xml`,
|
|
131
|
+
`${directory}${sceneName}.xml`,
|
|
132
|
+
...floors.flatMap( ( name ) => [
|
|
133
|
+
`${directory}${name.replace( /\\/g, '/' )}`,
|
|
134
|
+
`${directory}${component}.sbmx`
|
|
135
|
+
] )
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
assert.deepEqual( signingKeys, expectedKeys )
|
|
139
|
+
assert.deepEqual( storageKeys, expectedKeys )
|
|
140
|
+
assert.equal( loader.treeData.length, floors.length )
|
|
141
|
+
assert.equal( loader.treeData[ 0 ].children[ 0 ].name, component )
|
|
142
|
+
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test( 'builder preview requests complete floor names containing # before signing', async ( t ) => {
|
|
146
|
+
|
|
147
|
+
await loadProject( t, {
|
|
148
|
+
directory: '2026-10-20/project/builder/9nzxlpl2yk1s/',
|
|
149
|
+
sceneName: '兼容性测试-2.2.7.zip',
|
|
150
|
+
floors: [
|
|
151
|
+
'兼容性测试-2.2.7.zip22_地下层01#T_0.sbmx',
|
|
152
|
+
'兼容性测试-2.2.7.zip22_地上01#层T_1.sbmx'
|
|
153
|
+
],
|
|
154
|
+
component: '设备#1',
|
|
155
|
+
} )
|
|
156
|
+
|
|
157
|
+
} )
|
|
158
|
+
|
|
159
|
+
test( 'builder query URLs preserve special characters, nested paths and literal escapes', async ( t ) => {
|
|
160
|
+
|
|
161
|
+
await loadProject( t, {
|
|
162
|
+
directory: 'project/目录 # &+%23/',
|
|
163
|
+
sceneName: '场景 #&+?%23',
|
|
164
|
+
floors: [ '模型/地下层 #&+?%23.sbmx', '模型\\地上层 #2.sbmx' ],
|
|
165
|
+
component: '设备/门 #&+?%23',
|
|
166
|
+
query: true,
|
|
167
|
+
} )
|
|
168
|
+
|
|
169
|
+
} )
|
|
170
|
+
|
|
171
|
+
test( 'builder preview still loads ordinary resource names', async ( t ) => {
|
|
172
|
+
|
|
173
|
+
await loadProject( t, {
|
|
174
|
+
directory: 'project/builder/example/',
|
|
175
|
+
sceneName: 'scene',
|
|
176
|
+
floors: [ 'floor-1.sbmx', 'models/floor-2.sbmx' ],
|
|
177
|
+
component: 'equipment',
|
|
178
|
+
} )
|
|
179
|
+
|
|
180
|
+
} )
|