PageSourceSearch

https://www.eenetzwerk.de/assets/webcad/editor_state-3751ddd5.js

js eenetzwerk.de collected 2026-10-08 12:02:40 UTC 17,586 bytes, 410 lines download raw bytes

1// The editor's state: the drawn building, its storeys and their parts, the
2// active storey, the current tool and view, and the selection — in ONE place
3// with change notifications. In the prototype all of this lived as fields on
4// the root controller, so no other controller could read it cleanly; here the
5// store is the only mutable object, every tool controller subscribes to it.
6//
7//   state = createEditorState()
8//   const off = subscribe(state, (event) => …)   // { type, … }
9//   loadBuilding(state, buildingJson)
10//
11// Rules from the interaction concept (integration plan, "Drei Fallen der
12// Sandbox"): a storey is ALWAYS active (the first one by default); picking a
13// drawing tool means drawing, so it clears the selection — the right panel
14// then shows the tool's presets; 'select' is a tool of its own.
15//
16// Parts are kept per storey and per kind (walls, spaces, slabs, …), keyed by
17// the storey id. Mutation responses are inserted directly with upsertPart —
18// there is no reload after every change. Ids are numbers. Selection and
19// viewport values inside the store are replaced, never mutated (see
20// selection.js / viewport.js). Pure JS, tested with bin/js-test.
21import * as Selection from 'webcad/selection'
22
23export const SELECT_TOOL = 'select'
24// Parts that belong to the site, not to a storey: kept once per building.
25export const SITE_KINDS = ['structures', 'earthworks']
26
27export function createEditorState () {
28  return {
29    building: null, // { id, global_id, name, storeys: [...] } minus the parts
30    site: null, // the site JSON: terrain, ground, light-well defaults
31    grid: null, // the grid of axes: one per building, valid across storeys
32    siteParts: { structures: [], earthworks: [] },
33    storeys: [], // in position order
34    parts: new Map(), // storeyId → { kind → [part] }
35    activeStoreyId: null,
36    tool: SELECT_TOOL,
37    subTool: null,
38    viewMode: '2d',
39    selection: Selection.createSelection(),
40    // What the left panel picked for the inspector when nothing in the plan
41    // is selected: { kind: 'building' | 'settings' | 'storey' | 'preset', key }.
42    focus: null,
43    listeners: new Set()
44  }
45}
46
47// ── Change notifications ────────────────────────────────────────────────────
48
49export function subscribe (state, listener) {
50  state.listeners.add(listener)
51  return () => state.listeners.delete(listener)
52}
53
54function emit (state, event) {
55  for (const listener of [...state.listeners]) listener(event, state)
56}
57
58// ── Building and storeys ────────────────────────────────────────────────────
59
60// Take over the building JSON. Storey arrays (walls, spaces, …) become the
61// parts of that storey; the active storey stays if it still exists,
62// otherwise the first storey becomes active.
63// Which storey the editor starts on when none is remembered: the ground
64// floor, because that is where a building is drawn from. Only when there is
65// none does the lowest one take over — starting in the basement is the wrong
66// place to land, even on the very first visit.
67export function defaultStoreyId (storeys) {
68  const ground = storeys.find(s => s.storey_type === 'eg')
69  return (ground || storeys[0])?.id ?? null
70}
71
72// `preferredStorey` is what the editor remembered from last time; it is
73// honoured only if that storey still exists.
74export function loadBuilding (state, building, preferredStorey = null) {
75  const storeys = [...(building?.storeys || [])].sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
76  state.building = building ? { ...building, storeys: storeys.map(s => withoutParts(s)) } : null
77  state.site = building?.site || state.site
78  state.storeys = state.building ? state.building.storeys : []
79  state.parts = new Map()
80  for (const storey of storeys) state.parts.set(storey.id, partsIn(storey))
81  const stillThere = state.storeys.some(s => s.id === state.activeStoreyId)
82  const remembered = state.storeys.some(s => s.id === preferredStorey) ? preferredStorey : null
83  state.activeStoreyId = stillThere ? state.activeStoreyId : (remembered ?? defaultStoreyId(state.storeys))
84  state.selection = Selection.createSelection()
85  emit(state, { type: 'building', building: state.building })
86  emit(state, { type: 'storey', storeyId: state.activeStoreyId })
87  return state
88}
89
90export function activeStorey (state) {
91  return state.storeys.find(s => s.id === state.activeStoreyId) || null
92}
93
94export function storeyById (state, storeyId) {
95  return state.storeys.find(s => s.id === storeyId) || null
96}
97
98// Switch the active storey. Unknown ids are ignored; the selection is
99// dropped because it referred to parts of the previous storey.
100export function setActiveStorey (state, storeyId) {
101  if (storeyId === state.activeStoreyId || !storeyById(state, storeyId)) return state
102  state.activeStoreyId = storeyId
103  state.selection = Selection.createSelection()
104  emit(state, { type: 'storey', storeyId })
105  emit(state, { type: 'selection', selection: state.selection })
106  return state
107}
108
109// ── Focus (the left panel's pick for the inspector) ────────────────────────
110
111// Focus something from the left panel. A part selected in the plan always
112// wins over it, so focusing clears the selection; a drawing tool clears the
113// focus in turn (setTool). null clears.
114export function setFocus (state, focus) {
115  const next = focus ? { kind: focus.kind, key: focus.key ?? null } : null
116  const same = (!next && !state.focus) || (next && state.focus && next.kind === state.focus.kind && next.key === state.focus.key)
117  if (same) return state
118  state.focus = next
119  if (focus && !Selection.isEmpty(state.selection)) {
120    state.selection = Selection.createSelection()
121    emit(state, { type: 'selection', selection: state.selection })
122  }
123  emit(state, { type: 'focus', focus: state.focus })
124  return state
125}
126
127export function clearFocus (state) {
128  return setFocus(state, null)
129}
130
131// Add or replace a storey (create/update response) and keep the order.
132export function upsertStorey (state, storey) {
133  const clean = withoutParts(storey)
134  const idx = state.storeys.findIndex(s => s.id === storey.id)
135  if (idx >= 0) state.storeys[idx] = clean
136  else state.storeys.push(clean)
137  state.storeys.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
138  if (!state.parts.has(storey.id)) state.parts.set(storey.id, {})
139  const incoming = partsIn(storey)
140  if (Object.keys(incoming).length) state.parts.set(storey.id, { ...state.parts.get(storey.id), ...incoming })
141  if (state.building) state.building.storeys = state.storeys
142  if (state.activeStoreyId == null) state.activeStoreyId = storey.id
143  emit(state, { type: 'storeys', storeys: state.storeys })
144  return state
145}
146
147export function removeStorey (state, storeyId) {
148  const idx = state.storeys.findIndex(s => s.id === storeyId)
149  if (idx < 0) return state
150  state.storeys.splice(idx, 1)
151  state.parts.delete(storeyId)
152  if (state.building) state.building.storeys = state.storeys
153  emit(state, { type: 'storeys', storeys: state.storeys })
154  if (state.activeStoreyId === storeyId) {
155    if (state.storeys.length) {
156      setActiveStorey(state, state.storeys[0].id)
157    } else {
158      // The last storey is gone — the only moment without an active storey.
159      state.activeStoreyId = null
160      state.selection = Selection.createSelection()
161      emit(state, { type: 'storey', storeyId: null })
162    }
163  }
164  return state
165}
166
167// ── The site ────────────────────────────────────────────────────────────────
168
169export function setSite (state, site) {
170  state.site = site
171  emit(state, { type: 'site', site })
172  return state
173}
174
175// ── The grid ────────────────────────────────────────────────────────────────
176
177export function setGrid (state, grid) {
178  state.grid = grid
179  emit(state, { type: 'grid', grid })
180  return state
181}
182
183// ── Parts ───────────────────────────────────────────────────────────────────
184
185// The parts of one kind in a storey (the active one by default). Always an
186// array, never undefined.
187export function partsOf (state, kind, storeyId = state.activeStoreyId) {
188  if (SITE_KINDS.includes(kind)) return state.siteParts[kind] || []
189  return state.parts.get(storeyId)?.[kind] || []
190}
191
192// Replace a whole kind of a storey (an index response).
193export function setParts (state, storeyId, kind, parts) {
194  if (SITE_KINDS.includes(kind)) {
195    state.siteParts[kind] = [...parts]
196    emit(state, { type: 'parts', storeyId: null, kind })
197    return state
198  }
199  if (!state.parts.has(storeyId)) state.parts.set(storeyId, {})
200  state.parts.get(storeyId)[kind] = [...parts]
201  emit(state, { type: 'parts', storeyId, kind })
202  return state
203}
204
205// Insert or replace one part (a create/update response). A part that moved
206// to another storey is taken out of the old one.
207export function upsertPart (state, storeyId, kind, part) {
208  if (SITE_KINDS.includes(kind)) {
209    const list = [...(state.siteParts[kind] || [])]
210    const idx = list.findIndex(p => p.id === part.id)
211    if (idx >= 0) list[idx] = part
212    else list.push(part)
213    state.siteParts[kind] = list
214    emit(state, { type: 'parts', storeyId: null, kind, id: part.id })
215    return state
216  }
217  const found = findPart(state, kind, part.id)
218  if (found && found.storeyId !== storeyId) removeFrom(state, found.storeyId, kind, part.id)
219  if (!state.parts.has(storeyId)) state.parts.set(storeyId, {})
220  const bucket = state.parts.get(storeyId)
221  const list = bucket[kind] ? [...bucket[kind]] : []
222  const idx = list.findIndex(p => p.id === part.id)
223  if (idx >= 0) list[idx] = part
224  else list.push(part)
225  bucket[kind] = list
226  emit(state, { type: 'parts', storeyId, kind, id: part.id })
227  return state
228}
229
230// Drop one part (a destroy response) and take it out of the selection.
231export function removePart (state, kind, id) {
232  const found = findPart(state, kind, id)
233  if (!found) return state
234  removeFrom(state, found.storeyId, kind, id)
235  const selectionKind = selectionKindOf(kind)
236  if (Selection.isSelected(state.selection, selectionKind, id)) {
237    state.selection = Selection.remove(state.selection, selectionKind, id)
238    emit(state, { type: 'selection', selection: state.selection })
239  }
240  emit(state, { type: 'parts', storeyId: found.storeyId, kind, id })
241  return state
242}
243
244function removeFrom (state, storeyId, kind, id) {
245  if (SITE_KINDS.includes(kind)) {
246    state.siteParts[kind] = (state.siteParts[kind] || []).filter(p => p.id !== id)
247    return
248  }
249  const bucket = state.parts.get(storeyId)
250  if (!bucket?.[kind]) return
251  bucket[kind] = bucket[kind].filter(p => p.id !== id)
252}
253
254// Find a part of a kind across all storeys: { part, storeyId } or null.
255// Two kinds live inside other parts: a PV module inside its array, a PV
256// keep-out zone inside its roof (id 'roofId:index').
257export function findPart (state, kind, id) {
258  // A terrain point is a vertex of the site's height field, addressed by its index.
259  if (kind === 'terrain_points') {
260    const vertex = state.site?.terrain_points?.vertices?.[id]
261    return vertex ? { part: { ...vertex, index: id }, storeyId: null } : null
262  }
263  if (kind === 'pv_modules') {
264    for (const [storeyId, bucket] of state.parts) {
265      for (const array of bucket.pv_arrays || []) {
266        const module = (array.modules || []).find(m => m.id === id)
267        if (module) return { part: module, storeyId }
268      }
269    }
270    return null
271  }
272  if (kind === 'grid_axes') {
273    const axis = (state.grid?.axes || []).find(a => a.id === id)
274    return axis ? { part: axis, storeyId: null } : null
275  }
276  if (kind === 'pv_keepouts') {
277    const [roofId, index] = String(id).split(':').map(Number)
278    const found = findPart(state, 'roofs', roofId)
279    const zone = found?.part.pv_keepout_zones?.[index]
280    return zone ? { part: { id, roof_id: roofId, index, zone, roof: found.part }, storeyId: found.storeyId } : null
281  }
282  // The terrain surface is the site itself — one per building.
283  if (kind === 'terrains') {
284    return state.site && state.site.id === id ? { part: state.site, storeyId: null } : null
285  }
286  if (SITE_KINDS.includes(kind)) {
287    const part = (state.siteParts[kind] || []).find(p => p.id === id)
288    return part ? { part, storeyId: null } : null
289  }
290  for (const [storeyId, bucket] of state.parts) {
291    const part = bucket[kind]?.find(p => p.id === id)
292    if (part) return { part, storeyId }
293  }
294  return null
295}
296
297// A PV module changed on its own (wired, released): replace it inside its
298// array, so the array is a new object and the plan redraws.
299export function updatePvModule (state, module) {
300  const found = findPart(state, 'pv_arrays', module.pv_array_id)
301  if (!found) return state
302  const modules = found.part.modules.map(m => (m.id === module.id ? module : m))
303  return upsertPart(state, found.storeyId, 'pv_arrays', { ...found.part, modules })
304}
305
306// ── Tool and view ───────────────────────────────────────────────────────────
307
308// Choose a tool (and optionally its sub-tool). A drawing tool clears the
309// selection so the right panel shows its presets.
310export function setTool (state, tool, subTool = null) {
311  if (tool === state.tool && subTool === state.subTool) return state
312  state.tool = tool
313  state.subTool = subTool
314  emit(state, { type: 'tool', tool, subTool })
315  if (tool !== SELECT_TOOL) {
316    if (!Selection.isEmpty(state.selection)) {
317      state.selection = Selection.createSelection()
318      emit(state, { type: 'selection', selection: state.selection })
319    }
320    clearFocus(state)
321  }
322  return state
323}
324
325export function setSubTool (state, subTool) {
326  return setTool(state, state.tool, subTool)
327}
328
329export function setViewMode (state, viewMode) {
330  if (viewMode === state.viewMode) return state
331  state.viewMode = viewMode
332  emit(state, { type: 'view', viewMode })
333  return state
334}
335
336// ── Selection ───────────────────────────────────────────────────────────────
337
338// Replace the selection with the result of a selection.js function. A part
339// picked in the plan takes the inspector from whatever the left panel chose.
340export function setSelection (state, selection) {
341  if (selection === state.selection) return state
342  state.selection = selection
343  emit(state, { type: 'selection', selection })
344  if (!Selection.isEmpty(selection)) clearFocus(state)
345  return state
346}
347
348export function selectPart (state, kind, id) {
349  return setSelection(state, Selection.select(state.selection, kind, id))
350}
351
352// Several parts at once — a group picked in the plan, or a multi-selection
353// narrowed down to one kind.
354export function selectParts (state, parts, primary = null) {
355  return setSelection(state, Selection.selectMany(state.selection, parts, primary))
356}
357
358export function toggleSelected (state, kind, id) {
359  return setSelection(state, Selection.toggle(state.selection, kind, id))
360}
361
362export function clearSelection (state) {
363  return setSelection(state, Selection.clear(state.selection))
364}
365
366// The primary part with its data, for the right panel: { kind, id, part } or null.
367export function primaryPart (state) {
368  const primary = state.selection.primary
369  if (!primary) return null
370  const found = findPart(state, partsKindOf(primary.kind), primary.id)
371  return { kind: primary.kind, id: primary.id, part: found?.part || null, storeyId: found?.storeyId ?? null }
372}
373
374// ── Kinds ───────────────────────────────────────────────────────────────────
375// Parts are stored under the JSON collection name ('walls'); the selection
376// speaks of one part ('wall'). The two are the plural/singular of each other.
377
378// The one plural that is not "+s".
379const IRREGULAR_PLURALS = { grid_axis: 'grid_axes' }
380
381export function selectionKindOf (partsKind) {
382  const irregular = Object.keys(IRREGULAR_PLURALS).find(k => IRREGULAR_PLURALS[k] === partsKind)
383  if (irregular) return irregular
384  return partsKind.endsWith('s') ? partsKind.slice(0, -1) : partsKind
385}
386
387export function partsKindOf (selectionKind) {
388  if (IRREGULAR_PLURALS[selectionKind]) return IRREGULAR_PLURALS[selectionKind]
389  return selectionKind.endsWith('s') ? selectionKind : `${selectionKind}s`
390}
391
392// Every array-valued attribute of a storey payload is a parts collection.
393function partsIn (storey) {
394  const out = {}
395  for (const [key, value] of Object.entries(storey || {})) {
396    if (Array.isArray(value) && !STOREY_LISTS.includes(key)) out[key] = [...value]
397  }
398  return out
399}
400
401// A storey's own lists that are not parts: they stay with the storey.
402const STOREY_LISTS = ['corner_overrides']
403
404function withoutParts (storey) {
405  const out = {}
406  for (const [key, value] of Object.entries(storey || {})) {
407    if (!Array.isArray(value) || STOREY_LISTS.includes(key)) out[key] = value
408  }
409  return out
410}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.