@eturnity/eturnity_3d 9.40.0 → 9.40.1-EXP-84.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eturnity/eturnity_3d",
3
3
  "private": false,
4
- "version": "9.40.0",
4
+ "version": "9.40.1-EXP-84.0",
5
5
  "files": [
6
6
  "dist",
7
7
  "src"
@@ -17,7 +17,7 @@
17
17
  "merge-remote-master": "node scripts/merge-remote-master.js"
18
18
  },
19
19
  "dependencies": {
20
- "@eturnity/eturnity_maths": "9.40.0",
20
+ "@eturnity/eturnity_maths": "9.40.1",
21
21
  "@originjs/vite-plugin-commonjs": "1.0.3",
22
22
  "core-js": "3.30.2",
23
23
  "cors": "2.8.5",
@@ -49,6 +49,10 @@ export default {
49
49
  cylinder.visible = false
50
50
  }
51
51
  if (['obstacle', 'construction'].includes(edge.layer)) return
52
+ // The new engine draws the module field bound on the 2D canvas instead.
53
+ if (edge.layer === 'moduleField' && this.isNewPanelPlacementEngine) {
54
+ return
55
+ }
52
56
  if (
53
57
  ['selectRoof'].includes(this.selectedTool) &&
54
58
  edge.layer != 'roof'
@@ -0,0 +1,71 @@
1
+ import * as THREE from 'three'
2
+ import edgeRender from './edge'
3
+
4
+ const { methods } = edgeRender
5
+
6
+ function edge(id, layer, itemId) {
7
+ return {
8
+ id,
9
+ layer,
10
+ belongsTo: [{ itemId }],
11
+ outline: [
12
+ { x: 0, y: 0, z: 0 },
13
+ { x: 1000, y: 0, z: 0 },
14
+ ],
15
+ }
16
+ }
17
+
18
+ function makeCtx({ edges, selectedTool, isNewPanelPlacementEngine }) {
19
+ const ctx = {
20
+ edges,
21
+ selectedTool,
22
+ isNewPanelPlacementEngine,
23
+ selectedModuleField: { id: 'mf-1' },
24
+ scene: new THREE.Scene(),
25
+ meshes: { edgeMeshes: {} },
26
+ material: { edge: new THREE.MeshBasicMaterial() },
27
+ clearEdgesRemovedOnes: jest.fn(),
28
+ }
29
+ ctx.setCylinderPosition = methods.setCylinderPosition.bind(ctx)
30
+ ctx.renderEdges = methods.renderEdges.bind(ctx)
31
+ return ctx
32
+ }
33
+
34
+ describe('renderEdges and module field edges', () => {
35
+ const moduleFieldEdge = edge('e-mf', 'moduleField', 'mf-1')
36
+
37
+ it('draws the selected module field outline on the legacy engine', () => {
38
+ const ctx = makeCtx({
39
+ edges: [moduleFieldEdge],
40
+ selectedTool: 'selectModuleField',
41
+ isNewPanelPlacementEngine: false,
42
+ })
43
+ ctx.renderEdges()
44
+ expect(ctx.meshes.edgeMeshes['e-mf']).toBeInstanceOf(THREE.Mesh)
45
+ expect(ctx.scene.children).toHaveLength(1)
46
+ })
47
+
48
+ it('skips module field edges on the new engine, which draws its own bound', () => {
49
+ const ctx = makeCtx({
50
+ edges: [moduleFieldEdge],
51
+ selectedTool: 'selectModuleField',
52
+ isNewPanelPlacementEngine: true,
53
+ })
54
+ ctx.renderEdges()
55
+ expect(ctx.meshes.edgeMeshes['e-mf']).toBeUndefined()
56
+ expect(ctx.scene.children).toHaveLength(0)
57
+ })
58
+
59
+ it('keeps drawing roof edges on both engines', () => {
60
+ const roofEdge = edge('e-roof', 'roof', 'roof-1')
61
+ ;[false, true].forEach((isNewPanelPlacementEngine) => {
62
+ const ctx = makeCtx({
63
+ edges: [roofEdge],
64
+ selectedTool: 'drawRoof',
65
+ isNewPanelPlacementEngine,
66
+ })
67
+ ctx.renderEdges()
68
+ expect(ctx.meshes.edgeMeshes['e-roof']).toBeInstanceOf(THREE.Mesh)
69
+ })
70
+ })
71
+ })
@@ -3,6 +3,10 @@ import {
3
3
  getBufferModuleFieldGeometry,
4
4
  getFittingPanelsBufferGeometry,
5
5
  } from './geometryHandler'
6
+ import { clipModuleFieldFittingPanels } from '../UpdateRoofModuleFieldRelations'
7
+
8
+ const PANEL_OVERRIDE_TYPE = 'moduleFieldsMeshes_panelOverride'
9
+ const FITTING_PANELS_TYPE = 'moduleFieldsMeshes_fittingPanels'
6
10
 
7
11
  export default {
8
12
  data() {
@@ -11,35 +15,150 @@ export default {
11
15
  }
12
16
  },
13
17
  methods: {
18
+ /**
19
+ * Recomputes the fitting panels of every module field from its
20
+ * current panels and re-renders. The new panel placement engine changes
21
+ * panels without a backend re-layout, so nothing else refreshes them.
22
+ */
23
+ refreshModuleFieldFittingPanels() {
24
+ this.polygons
25
+ .filter((p) => p.layer === 'moduleField' && p.data)
26
+ .forEach((moduleField) => clipModuleFieldFittingPanels(moduleField))
27
+ this.renderModuleFields()
28
+ },
29
+ /**
30
+ * Incremental: one group per module field survives across renders and its
31
+ * meshes are updated in place, so a live transform never rebuilds the layer.
32
+ */
14
33
  renderModuleFields() {
15
- this.clearByType('moduleFieldsMeshes')
34
+ this.clearRemovedModuleFieldGroups()
16
35
  const moduleFieldPolygons = this.polygons.filter((p) =>
17
36
  ['moduleField', 'tmpModuleField'].includes(p.layer)
18
37
  )
19
38
  moduleFieldPolygons.forEach((moduleFieldPolygon) => {
20
- const panelOverrideMesh =
21
- this.renderModuleFieldPanelOverride(moduleFieldPolygon)
22
- const fittingPanelsMesh =
23
- this.renderModuleFieldFittingPanels(moduleFieldPolygon)
39
+ let group = this.meshes.moduleFieldsMeshes[moduleFieldPolygon.id]
40
+ if (!group) {
41
+ group = new THREE.Group()
42
+ group.matrixAutoUpdate = false
43
+ group.userData.type = 'moduleFieldsMeshes'
44
+ group.userData.layer = moduleFieldPolygon.layer
45
+ group.userData.meshId = moduleFieldPolygon.id
46
+ group.userData.polygonId = moduleFieldPolygon.id
47
+ this.scene.add(group)
48
+ this.meshes.moduleFieldsMeshes[moduleFieldPolygon.id] = group
49
+ }
24
50
 
25
- if (!panelOverrideMesh && !fittingPanelsMesh) return
51
+ this.updateModuleFieldPanelOverrideMesh(group, moduleFieldPolygon)
52
+ this.updateModuleFieldFittingPanelsMesh(group, moduleFieldPolygon)
26
53
 
27
- const meshList = [panelOverrideMesh, fittingPanelsMesh]
28
- const group = new THREE.Group()
29
- meshList.forEach((mesh) => {
30
- if (mesh) group.add(mesh)
31
- })
32
- group.matrixAutoUpdate = false
33
54
  group.userData.version = moduleFieldPolygon.version
34
- group.userData.type = 'moduleFieldsMeshes'
35
- group.userData.layer = moduleFieldPolygon.layer
36
- group.userData.meshId = moduleFieldPolygon.id
37
- group.userData.polygonId = moduleFieldPolygon.id
38
55
  group.visible = this.areModuleFieldsVisible && this.panelOpacity === 1
39
- this.scene.add(group)
40
- this.meshes.moduleFieldsMeshes[moduleFieldPolygon.id] = group
56
+
57
+ if (group.children.length === 0) {
58
+ this.scene.remove(group)
59
+ delete this.meshes.moduleFieldsMeshes[moduleFieldPolygon.id]
60
+ }
61
+ })
62
+ },
63
+ clearRemovedModuleFieldGroups() {
64
+ const polygonIds = new Set(this.polygons.map((p) => p.id))
65
+ Object.values(this.meshes.moduleFieldsMeshes).forEach((group) => {
66
+ if (!polygonIds.has(group.userData.polygonId)) {
67
+ this.disposeModuleFieldGroupChildren(group)
68
+ this.scene.remove(group)
69
+ delete this.meshes.moduleFieldsMeshes[group.userData.meshId]
70
+ }
71
+ })
72
+ },
73
+ disposeModuleFieldGroupChildren(group) {
74
+ group.children.forEach((child) => {
75
+ child.geometry?.dispose?.()
76
+ // The override material is shared from materialMixin and must survive.
77
+ if (child.userData.type === FITTING_PANELS_TYPE) {
78
+ child.material?.dispose?.()
79
+ }
80
+ })
81
+ },
82
+ updateModuleFieldPanelOverrideMesh(group, moduleFieldPolygon) {
83
+ let mesh = group.children.find(
84
+ (c) => c.userData.type === PANEL_OVERRIDE_TYPE
85
+ )
86
+ const shouldExist = !!moduleFieldPolygon?.data?.number_of_panels_override
87
+
88
+ if (!shouldExist) {
89
+ if (mesh) {
90
+ group.remove(mesh)
91
+ mesh.geometry?.dispose?.()
92
+ }
93
+ return
94
+ }
95
+
96
+ if (!mesh) {
97
+ mesh = this.renderModuleFieldPanelOverride(moduleFieldPolygon)
98
+ if (mesh) group.add(mesh)
41
99
  return
100
+ }
101
+
102
+ if (mesh.userData.version !== moduleFieldPolygon.version) {
103
+ const geometry = getBufferModuleFieldGeometry(moduleFieldPolygon)
104
+ if (geometry) {
105
+ mesh.geometry?.dispose?.()
106
+ mesh.geometry = geometry
107
+ mesh.userData.version = moduleFieldPolygon.version
108
+ mesh.updateMatrix()
109
+ }
110
+ }
111
+ },
112
+ updateModuleFieldFittingPanelsMesh(group, moduleFieldPolygon) {
113
+ let mesh = group.children.find(
114
+ (c) => c.userData.type === FITTING_PANELS_TYPE
115
+ )
116
+ const fittingPanels = moduleFieldPolygon?.fittingPanels
117
+
118
+ const removeMesh = () => {
119
+ if (!mesh) return
120
+ group.remove(mesh)
121
+ mesh.geometry?.dispose?.()
122
+ mesh.material?.dispose?.()
123
+ }
124
+
125
+ if (!fittingPanels?.polygons?.length) {
126
+ removeMesh()
127
+ return
128
+ }
129
+
130
+ if (!mesh) {
131
+ mesh = this.renderModuleFieldFittingPanels(moduleFieldPolygon)
132
+ if (mesh) group.add(mesh)
133
+ return
134
+ }
135
+
136
+ const { polygons, visible, color } = fittingPanels
137
+ const geometries = []
138
+ polygons.forEach((polygon) => {
139
+ const geometry = getFittingPanelsBufferGeometry(
140
+ polygon.outline,
141
+ polygon.holes,
142
+ moduleFieldPolygon
143
+ )
144
+ if (geometry) geometries.push(geometry)
42
145
  })
146
+
147
+ if (!geometries.length) {
148
+ removeMesh()
149
+ return
150
+ }
151
+
152
+ mesh.geometry = this.updateMergedGeometry(geometries, mesh.geometry)
153
+ const isTransparent = color === 'transparent'
154
+ mesh.material.color.set(isTransparent ? '#ffffff' : color)
155
+ mesh.material.transparent = true
156
+ mesh.material.opacity = isTransparent ? 0.2 : 0.8
157
+ mesh.material.needsUpdate = true
158
+ mesh.userData.fittingPanelsVisible = visible
159
+ mesh.userData.version = moduleFieldPolygon.version
160
+ mesh.visible =
161
+ visible && this.areModuleFieldsVisible && this.panelOpacity === 1
43
162
  },
44
163
  renderModuleFieldPanelOverride(moduleFieldPolygon) {
45
164
  if (!moduleFieldPolygon?.data?.number_of_panels_override) return
@@ -49,7 +168,7 @@ export default {
49
168
  const mesh = new THREE.Mesh(geometry, material)
50
169
  mesh.matrixAutoUpdate = false
51
170
  mesh.userData.version = moduleFieldPolygon.version
52
- mesh.userData.type = 'moduleFieldsMeshes_panelOverride'
171
+ mesh.userData.type = PANEL_OVERRIDE_TYPE
53
172
  mesh.userData.layer = moduleFieldPolygon.layer
54
173
  mesh.userData.meshId = moduleFieldPolygon.id
55
174
  mesh.userData.polygonId = moduleFieldPolygon.id
@@ -84,10 +203,13 @@ export default {
84
203
  })
85
204
 
86
205
  const mesh = new THREE.Mesh(mergedGeometry, material)
87
- mesh.visible = visible
206
+ mesh.visible =
207
+ visible && this.areModuleFieldsVisible && this.panelOpacity === 1
88
208
  mesh.matrixAutoUpdate = false
209
+ mesh.frustumCulled = false
210
+ mesh.userData.fittingPanelsVisible = visible
89
211
  mesh.userData.version = moduleFieldPolygon.version
90
- mesh.userData.type = 'moduleFieldsMeshes_fittingPanels'
212
+ mesh.userData.type = FITTING_PANELS_TYPE
91
213
  mesh.userData.layer = moduleFieldPolygon.layer
92
214
  mesh.userData.meshId = moduleFieldPolygon.id
93
215
  mesh.userData.polygonId = moduleFieldPolygon.id
@@ -96,11 +218,17 @@ export default {
96
218
  },
97
219
  updateModuleFieldsVisibility(isVisible) {
98
220
  this.areModuleFieldsVisible = isVisible
99
- Object.values(this.meshes['moduleFieldsMeshes']).forEach(
100
- (mesh) =>
101
- (mesh.visible =
102
- this.areModuleFieldsVisible && this.panelOpacity === 1)
103
- )
221
+ const moduleFieldsVisible =
222
+ this.areModuleFieldsVisible && this.panelOpacity === 1
223
+ Object.values(this.meshes['moduleFieldsMeshes']).forEach((group) => {
224
+ group.visible = moduleFieldsVisible
225
+ group.children.forEach((child) => {
226
+ if (child.userData.type === FITTING_PANELS_TYPE) {
227
+ child.visible =
228
+ moduleFieldsVisible && child.userData.fittingPanelsVisible
229
+ }
230
+ })
231
+ })
104
232
  },
105
233
  hideModuleFields() {
106
234
  this.updateModuleFieldsVisibility(false)
@@ -0,0 +1,195 @@
1
+ jest.mock('./geometryHandler', () => ({
2
+ getBufferModuleFieldGeometry: jest.fn(),
3
+ getFittingPanelsBufferGeometry: jest.fn(),
4
+ }))
5
+ jest.mock('../UpdateRoofModuleFieldRelations', () => ({
6
+ clipModuleFieldFittingPanels: jest.fn(),
7
+ }))
8
+
9
+ import * as THREE from 'three'
10
+ import {
11
+ getBufferModuleFieldGeometry,
12
+ getFittingPanelsBufferGeometry,
13
+ } from './geometryHandler'
14
+ import { clipModuleFieldFittingPanels } from '../UpdateRoofModuleFieldRelations'
15
+ import moduleFieldRender from './moduleField'
16
+
17
+ const { methods } = moduleFieldRender
18
+
19
+ function fittingPanels({ visible = true, color = 'transparent' } = {}) {
20
+ return { visible, color, polygons: [{ outline: [], holes: [] }] }
21
+ }
22
+
23
+ function moduleField(id, overrides = {}) {
24
+ return { id, layer: 'moduleField', version: 1, data: {}, ...overrides }
25
+ }
26
+
27
+ function makeCtx(polygons, overrides = {}) {
28
+ const ctx = {
29
+ polygons,
30
+ scene: new THREE.Scene(),
31
+ meshes: { moduleFieldsMeshes: {} },
32
+ areModuleFieldsVisible: true,
33
+ panelOpacity: 1,
34
+ material: { moduleFieldWithPanelOveride: new THREE.MeshBasicMaterial() },
35
+ createMergedGeometry: () => new THREE.BufferGeometry(),
36
+ updateMergedGeometry: (geometries, existing) => existing,
37
+ }
38
+ Object.entries(methods).forEach(([name, method]) => {
39
+ ctx[name] = method.bind(ctx)
40
+ })
41
+ return Object.assign(ctx, overrides)
42
+ }
43
+
44
+ beforeEach(() => {
45
+ jest.clearAllMocks()
46
+ getFittingPanelsBufferGeometry.mockImplementation(
47
+ () => new THREE.BufferGeometry()
48
+ )
49
+ getBufferModuleFieldGeometry.mockImplementation(
50
+ () => new THREE.BufferGeometry()
51
+ )
52
+ })
53
+
54
+ describe('refreshModuleFieldFittingPanels', () => {
55
+ const roof = { id: 'roof-1', layer: 'roof', data: {} }
56
+ const tmpField = { id: 'tmp-1', layer: 'tmpModuleField', data: {} }
57
+ const fieldWithoutData = { id: 'mf-3', layer: 'moduleField' }
58
+ const first = moduleField('mf-1')
59
+ const second = moduleField(2)
60
+
61
+ it('clips every module field that has data, then re-renders once', () => {
62
+ const ctx = makeCtx([roof, first, tmpField, second, fieldWithoutData], {
63
+ renderModuleFields: jest.fn(),
64
+ })
65
+ ctx.refreshModuleFieldFittingPanels()
66
+ expect(clipModuleFieldFittingPanels.mock.calls.map(([p]) => p)).toEqual([
67
+ first,
68
+ second,
69
+ ])
70
+ expect(ctx.renderModuleFields).toHaveBeenCalledTimes(1)
71
+ })
72
+ })
73
+
74
+ describe('renderModuleFields', () => {
75
+ it('keeps one group per field across renders and updates it in place', () => {
76
+ const field = moduleField('mf-1', { fittingPanels: fittingPanels() })
77
+ const ctx = makeCtx([field])
78
+
79
+ ctx.renderModuleFields()
80
+ const group = ctx.meshes.moduleFieldsMeshes['mf-1']
81
+ expect(group).toBeInstanceOf(THREE.Group)
82
+ expect(ctx.scene.children).toEqual([group])
83
+ expect(group.children).toHaveLength(1)
84
+
85
+ field.version = 2
86
+ ctx.renderModuleFields()
87
+ expect(ctx.meshes.moduleFieldsMeshes['mf-1']).toBe(group)
88
+ expect(ctx.scene.children).toEqual([group])
89
+ expect(group.children).toHaveLength(1)
90
+ expect(group.userData.version).toBe(2)
91
+ })
92
+
93
+ it('leaves no group behind for a field with nothing to draw', () => {
94
+ const field = moduleField('mf-1', { fittingPanels: fittingPanels() })
95
+ const ctx = makeCtx([field])
96
+ ctx.renderModuleFields()
97
+
98
+ field.fittingPanels = { visible: true, color: 'transparent', polygons: [] }
99
+ ctx.renderModuleFields()
100
+ expect(ctx.meshes.moduleFieldsMeshes).toEqual({})
101
+ expect(ctx.scene.children).toEqual([])
102
+ })
103
+
104
+ it('drops the group of a field that left the polygons', () => {
105
+ const kept = moduleField('mf-1', { fittingPanels: fittingPanels() })
106
+ const removed = moduleField('mf-2', { fittingPanels: fittingPanels() })
107
+ const ctx = makeCtx([kept, removed])
108
+ ctx.renderModuleFields()
109
+ expect(Object.keys(ctx.meshes.moduleFieldsMeshes)).toEqual(['mf-1', 'mf-2'])
110
+
111
+ ctx.polygons = [kept]
112
+ ctx.renderModuleFields()
113
+ expect(Object.keys(ctx.meshes.moduleFieldsMeshes)).toEqual(['mf-1'])
114
+ expect(ctx.scene.children).toEqual([ctx.meshes.moduleFieldsMeshes['mf-1']])
115
+ })
116
+
117
+ it('adds the panel override mesh when the override appears and removes it when it goes', () => {
118
+ const field = moduleField('mf-1', { fittingPanels: fittingPanels() })
119
+ const ctx = makeCtx([field])
120
+ ctx.renderModuleFields()
121
+ const group = ctx.meshes.moduleFieldsMeshes['mf-1']
122
+ const typesOf = () => group.children.map((c) => c.userData.type).sort()
123
+ expect(typesOf()).toEqual(['moduleFieldsMeshes_fittingPanels'])
124
+
125
+ field.data = { number_of_panels_override: 3 }
126
+ field.version = 2
127
+ ctx.renderModuleFields()
128
+ expect(typesOf()).toEqual([
129
+ 'moduleFieldsMeshes_fittingPanels',
130
+ 'moduleFieldsMeshes_panelOverride',
131
+ ])
132
+
133
+ field.data = {}
134
+ field.version = 3
135
+ ctx.renderModuleFields()
136
+ expect(typesOf()).toEqual(['moduleFieldsMeshes_fittingPanels'])
137
+ })
138
+
139
+ // Undo hands the field back a version it already rendered at, so a
140
+ // "newer only" check would keep the trimmed slab.
141
+ it('rebuilds the override slab whenever the version differs, not only when it grows', () => {
142
+ const field = moduleField('mf-1', {
143
+ data: { number_of_panels_override: 3 },
144
+ version: 3,
145
+ })
146
+ const ctx = makeCtx([field])
147
+ ctx.renderModuleFields()
148
+ const [overrideMesh] = ctx.meshes.moduleFieldsMeshes['mf-1'].children
149
+ const trimmedGeometry = overrideMesh.geometry
150
+
151
+ const restoredGeometry = new THREE.BufferGeometry()
152
+ getBufferModuleFieldGeometry.mockReturnValueOnce(restoredGeometry)
153
+ field.version = 2
154
+ ctx.renderModuleFields()
155
+ expect(overrideMesh.geometry).toBe(restoredGeometry)
156
+ expect(overrideMesh.geometry).not.toBe(trimmedGeometry)
157
+ expect(overrideMesh.userData.version).toBe(2)
158
+ })
159
+ })
160
+
161
+ describe('renderModuleFieldFittingPanels', () => {
162
+ it('never frustum culls the mesh, whose bounding sphere goes stale on moves', () => {
163
+ const field = moduleField('mf-1', { fittingPanels: fittingPanels() })
164
+ const ctx = makeCtx([field])
165
+ ctx.renderModuleFields()
166
+ const [fittingMesh] = ctx.meshes.moduleFieldsMeshes['mf-1'].children
167
+ expect(fittingMesh.frustumCulled).toBe(false)
168
+ })
169
+ })
170
+
171
+ describe('updateModuleFieldsVisibility', () => {
172
+ it('hides hidden fitting panels even while the fields are shown', () => {
173
+ const field = moduleField('mf-1', {
174
+ fittingPanels: fittingPanels({ visible: false }),
175
+ })
176
+ const ctx = makeCtx([field])
177
+ ctx.renderModuleFields()
178
+ const group = ctx.meshes.moduleFieldsMeshes['mf-1']
179
+ const [fittingMesh] = group.children
180
+ expect(group.visible).toBe(true)
181
+ expect(fittingMesh.visible).toBe(false)
182
+
183
+ ctx.updateModuleFieldsVisibility(false)
184
+ expect(group.visible).toBe(false)
185
+
186
+ ctx.updateModuleFieldsVisibility(true)
187
+ expect(group.visible).toBe(true)
188
+ expect(fittingMesh.visible).toBe(false)
189
+
190
+ field.fittingPanels.visible = true
191
+ field.version = 2
192
+ ctx.renderModuleFields()
193
+ expect(fittingMesh.visible).toBe(true)
194
+ })
195
+ })
@@ -61,6 +61,15 @@ export default {
61
61
  this.hidePanels()
62
62
  }
63
63
  },
64
+ clearMergedPanelMeshes(moduleFieldId) {
65
+ ;[this.mergedPanelTopMeshes, this.mergedPanelSideMesh].forEach(
66
+ (registry) => {
67
+ if (!registry[moduleFieldId]) return
68
+ this.clearMeshFromScene(registry[moduleFieldId], this.scene)
69
+ delete registry[moduleFieldId]
70
+ }
71
+ )
72
+ },
64
73
  renderPanelsPerModuleField(moduleField) {
65
74
  const panelSideGeometries = []
66
75
  const panelTopGeometries = []
@@ -75,7 +84,13 @@ export default {
75
84
  existingTopMesh?.userData.panelOpacity !== this.panelOpacity
76
85
  const skipRender = existingTopMesh && !isNewerVersion && !opacityChanged
77
86
 
78
- if (!panels.length || skipRender) {
87
+ if (!panels.length) {
88
+ // The merged meshes outlive the panels otherwise, so the stale panels
89
+ // stay in the scene.
90
+ this.clearMergedPanelMeshes(moduleField.id)
91
+ return
92
+ }
93
+ if (skipRender) {
79
94
  return
80
95
  }
81
96
 
@@ -0,0 +1,59 @@
1
+ jest.mock('./geometryHandler', () => ({
2
+ getBufferPanelGeometry: jest.fn(),
3
+ updateBufferPanelGeometry: jest.fn(),
4
+ getBufferPanelSideGeometry: jest.fn(),
5
+ updateBufferPanelSideGeometry: jest.fn(),
6
+ }))
7
+
8
+ import panelRender from './panel'
9
+
10
+ const { methods } = panelRender
11
+
12
+ function makeCtx({ topMesh, sideMesh } = {}) {
13
+ const ctx = {
14
+ scene: { id: 'scene' },
15
+ panelOpacity: 1,
16
+ areUserDeactivatedPanelsVisible: false,
17
+ mergedPanelTopMeshes: topMesh ? { 'mf-1': topMesh } : {},
18
+ mergedPanelSideMesh: sideMesh ? { 'mf-1': sideMesh } : {},
19
+ meshes: { panelsMeshes: {} },
20
+ clearMeshFromScene: jest.fn(),
21
+ getPanelMaterial: jest.fn(),
22
+ }
23
+ ctx.clearMergedPanelMeshes = methods.clearMergedPanelMeshes.bind(ctx)
24
+ return ctx
25
+ }
26
+
27
+ describe('renderPanelsPerModuleField', () => {
28
+ it('drops the merged meshes of a field that lost its last panel', () => {
29
+ const topMesh = { userData: {} }
30
+ const sideMesh = { userData: {} }
31
+ const ctx = makeCtx({ topMesh, sideMesh })
32
+
33
+ methods.renderPanelsPerModuleField.call(ctx, { id: 'mf-1', panels: [] })
34
+
35
+ expect(ctx.clearMeshFromScene).toHaveBeenCalledWith(topMesh, ctx.scene)
36
+ expect(ctx.clearMeshFromScene).toHaveBeenCalledWith(sideMesh, ctx.scene)
37
+ expect(ctx.mergedPanelTopMeshes).toEqual({})
38
+ expect(ctx.mergedPanelSideMesh).toEqual({})
39
+ })
40
+
41
+ it('does nothing for an empty field that never had merged meshes', () => {
42
+ const ctx = makeCtx()
43
+ methods.renderPanelsPerModuleField.call(ctx, { id: 'mf-1', panels: [] })
44
+ expect(ctx.clearMeshFromScene).not.toHaveBeenCalled()
45
+ expect(ctx.getPanelMaterial).not.toHaveBeenCalled()
46
+ })
47
+
48
+ it('still skips an unchanged field with panels', () => {
49
+ const topMesh = { userData: { moduleFieldVersion: 4, panelOpacity: 1 } }
50
+ const ctx = makeCtx({ topMesh })
51
+ const field = { id: 'mf-1', version: 4, panels: [{ id: 'p-1' }] }
52
+
53
+ methods.renderPanelsPerModuleField.call(ctx, field)
54
+
55
+ expect(ctx.getPanelMaterial).not.toHaveBeenCalled()
56
+ expect(ctx.clearMeshFromScene).not.toHaveBeenCalled()
57
+ expect(ctx.mergedPanelTopMeshes['mf-1']).toBe(topMesh)
58
+ })
59
+ })
@@ -38,6 +38,10 @@ export default {
38
38
  selectedMapLayer: (state) => state.threeDModule.selectedMapLayer,
39
39
  panelOpacity: (state) => state.threeDModule.panelOpacity,
40
40
  pvDataMemo: (state) => state.threeDModule.pvDataMemo,
41
+ // Snapshot the host takes at layout mount; gates the renderer paths that
42
+ // only the new panel placement engine wants.
43
+ isNewPanelPlacementEngine: (state) =>
44
+ state.threeDModule?.isNewPanelPlacementEngineActive === true,
41
45
  }),
42
46
  ...mapGetters({
43
47
  selectedTool: 'getSelectedTool',
@@ -306,6 +310,15 @@ export default {
306
310
  method: this.renderObstacles,
307
311
  arg: [],
308
312
  })
313
+ } else if (
314
+ params.layer == 'moduleField' &&
315
+ this.isNewPanelPlacementEngine
316
+ ) {
317
+ methodList.push({
318
+ name: 'refreshModuleFieldFittingPanels',
319
+ method: this.refreshModuleFieldFittingPanels,
320
+ arg: [],
321
+ })
309
322
  }
310
323
  methodList.push({
311
324
  name: 'renderRoofsMargin',
@@ -318,6 +331,25 @@ export default {
318
331
  arg: [],
319
332
  })
320
333
  break
334
+ // Legacy emits this too, but its backend re-layout rebuilds the fitting
335
+ // panels afterwards; only the new engine needs the refresh.
336
+ case 'updateModuleFieldOutline':
337
+ if (this.isNewPanelPlacementEngine) {
338
+ methodList.push({
339
+ name: 'refreshModuleFieldFittingPanels',
340
+ method: this.refreshModuleFieldFittingPanels,
341
+ arg: [],
342
+ })
343
+ }
344
+ break
345
+ // New engine only: the grid changed without a backend re-layout.
346
+ case 'resizeModuleFieldGrid': //params.id
347
+ methodList.push({
348
+ name: 'refreshModuleFieldFittingPanels',
349
+ method: this.refreshModuleFieldFittingPanels,
350
+ arg: [],
351
+ })
352
+ // falls through
321
353
  case 'removePanels': //params.panels
322
354
  case 'removeModuleField': //params:moduleField
323
355
  case 'alignOnSelectedEdge':
@@ -336,6 +368,23 @@ export default {
336
368
  arg: [this.areModuleFieldsVisible],
337
369
  })
338
370
  break
371
+ case 'liveTransformPanels':
372
+ methodList.push({
373
+ name: 'renderPanels',
374
+ method: this.renderPanels,
375
+ arg: [],
376
+ })
377
+ methodList.push({
378
+ name: 'updateModuleFieldsVisibility',
379
+ method: this.updateModuleFieldsVisibility,
380
+ arg: [this.areModuleFieldsVisible],
381
+ })
382
+ methodList.push({
383
+ name: 'renderModuleFields',
384
+ method: this.renderModuleFields,
385
+ arg: [],
386
+ })
387
+ break
339
388
  case 'addPointToPolygon': //params.polygon,index
340
389
  case 'removeObstacle': //params:obstacle
341
390
  case 'removeRoof': //params:roof
@@ -0,0 +1,77 @@
1
+ jest.mock('./render', () => ({}))
2
+ jest.mock('./cameraMixin', () => ({}))
3
+
4
+ import renderMixin from './renderMixin'
5
+
6
+ const { methods, computed } = renderMixin
7
+
8
+ function namesFor(ctx, geometryEvent) {
9
+ return methods.renderingMethodsByEvent
10
+ .call({ areModuleFieldsVisible: true, ...ctx }, geometryEvent)
11
+ .map((m) => m.name)
12
+ }
13
+
14
+ describe('isNewPanelPlacementEngine', () => {
15
+ const read = (state) =>
16
+ computed.isNewPanelPlacementEngine.call({ $store: { state } })
17
+
18
+ it('mirrors the snapshot the host store took at layout mount', () => {
19
+ expect(
20
+ read({ threeDModule: { isNewPanelPlacementEngineActive: true } })
21
+ ).toBe(true)
22
+ })
23
+
24
+ it('is off without a snapshot or without the module', () => {
25
+ expect(read({ threeDModule: {} })).toBe(false)
26
+ expect(read({})).toBe(false)
27
+ })
28
+ })
29
+
30
+ describe('renderingMethodsByEvent and the fitting panel refresh', () => {
31
+ describe('updateOutlineXY on a module field', () => {
32
+ const event = { name: 'updateOutlineXY', params: { layer: 'moduleField' } }
33
+
34
+ it('leaves the legacy engine alone, whose backend re-layout follows', () => {
35
+ const names = namesFor({ isNewPanelPlacementEngine: false }, event)
36
+ expect(names).not.toContain('refreshModuleFieldFittingPanels')
37
+ expect(names).toEqual(['renderRoofsMargin', 'renderTiles'])
38
+ })
39
+
40
+ it('refreshes the fitting panels on the new engine', () => {
41
+ const names = namesFor({ isNewPanelPlacementEngine: true }, event)
42
+ expect(names).toEqual([
43
+ 'refreshModuleFieldFittingPanels',
44
+ 'renderRoofsMargin',
45
+ 'renderTiles',
46
+ ])
47
+ })
48
+ })
49
+
50
+ describe('updateModuleFieldOutline', () => {
51
+ const event = { name: 'updateModuleFieldOutline', params: { id: 'mf-7' } }
52
+
53
+ it('stays a no-op on the legacy engine', () => {
54
+ expect(namesFor({ isNewPanelPlacementEngine: false }, event)).toEqual([])
55
+ })
56
+
57
+ it('refreshes every field on the new engine, since refreshRendering keeps one call per method name', () => {
58
+ const ctx = {
59
+ isNewPanelPlacementEngine: true,
60
+ fullRenderNeeded: false,
61
+ geometryEventsToDoWithoutFull: [
62
+ event,
63
+ { name: 'updateModuleFieldOutline', params: { id: 'mf-8' } },
64
+ ],
65
+ renderingMethodsByEvent: methods.renderingMethodsByEvent,
66
+ renderMethods: jest.fn(),
67
+ render: jest.fn(),
68
+ }
69
+ methods.refreshRendering.call(ctx)
70
+ const [methodList] = ctx.renderMethods.mock.calls[0]
71
+ expect(methodList.map((m) => m.name)).toEqual([
72
+ 'refreshModuleFieldFittingPanels',
73
+ ])
74
+ expect(methodList[0].arg).toEqual([])
75
+ })
76
+ })
77
+ })
@@ -30,6 +30,7 @@ const init_state = {
30
30
  mountingDataMemo: [],
31
31
  wallColor: '#454545',
32
32
  panelOpacity: 1,
33
+ isNewPanelPlacementEngineActive: false,
33
34
  selectedMapLayer: null,
34
35
  globalProjectProperties: [],
35
36
  imageOverlay: null,