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.