@unson/brainbase-mcp 0.21.1 → 0.21.2

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.
@@ -347,7 +347,7 @@ export function createWorldExtension(options = {}) {
347
347
  return {
348
348
  id: WORLD_EXTENSION_ID,
349
349
  uiDir: fileURLToPath(new URL('../ui/world/', import.meta.url)),
350
- uiFiles: ['world-view.js', 'world-view.css', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js'],
350
+ uiFiles: ['world-view.js', 'world-view.css', 'world-vendor.js', 'world-placement.js', 'world-work-rail.js', 'world-district.js', 'world-scenery.js'],
351
351
  screenEntry: 'world-view.js',
352
352
  // P17: the world sits above the home and opens first.
353
353
  navPosition: 'first',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -364,5 +364,5 @@
364
364
  "engines": {
365
365
  "node": ">=20"
366
366
  },
367
- "gitHead": "5bbdd5ef6feb5b037f1f1d79c38818db2c3e23a6"
367
+ "gitHead": "da10b2a1bb7b6b34ee9ba38319c32338da6da3e1"
368
368
  }
@@ -14,7 +14,8 @@
14
14
  */
15
15
 
16
16
  import { THREE, MapControls } from './world-vendor.js';
17
- import { DISTRICT_STREETS, districtStreetLots } from './world-placement.js';
17
+ import { DISTRICT_STREETS, districtStreetLots, skyAt } from './world-placement.js';
18
+ import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
18
19
  import { makeWorkspaceElement as el } from '../../workspace-kit.js';
19
20
 
20
21
  export const WORLD_DISTRICT_CONTRACT_VERSION = 'brainbase.world-district.v0';
@@ -48,16 +49,6 @@ export const DISTRICT_LEGEND = Object.freeze([
48
49
  ['is-ambience', '通りを歩く人と煙=街の雰囲気(記録とは関係しません)'],
49
50
  ]);
50
51
 
51
- function canvasTexture(doc, width, height, draw) {
52
- const canvas = doc.createElement('canvas');
53
- canvas.width = width;
54
- canvas.height = height;
55
- draw(canvas.getContext('2d'), width, height);
56
- const texture = new THREE.CanvasTexture(canvas);
57
- texture.colorSpace = THREE.SRGBColorSpace;
58
- return texture;
59
- }
60
-
61
52
  export function createDistrictView({ doc, stage, reducedMotion = false, onPick, onClear, onEscape }) {
62
53
  const renderer = new THREE.WebGLRenderer({ antialias: true });
63
54
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
@@ -83,24 +74,54 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
83
74
  notice.hidden = true;
84
75
  stage.append(notice);
85
76
 
77
+ // The same sky, light and exposure as the world (scenery: it carries no data), following the hour or
78
+ // the sky the viewer fixed in the world.
86
79
  const scene = new THREE.Scene();
87
- scene.background = canvasTexture(doc, 16, 256, (ctx, w, h) => {
80
+ let skyMode = 'auto';
81
+ let sky = skyAt(new Date().getHours(), skyMode);
82
+ const skyTexture = (current) => canvasTexture(doc, 256, 256, (ctx, w, h) => {
88
83
  const gradient = ctx.createLinearGradient(0, 0, 0, h);
89
- gradient.addColorStop(0, '#a9cdea');
90
- gradient.addColorStop(0.6, '#e2eef0');
91
- gradient.addColorStop(1, '#f3efe3');
84
+ gradient.addColorStop(0, current.top);
85
+ gradient.addColorStop(0.55, current.middle);
86
+ gradient.addColorStop(1, current.bottom);
92
87
  ctx.fillStyle = gradient;
93
88
  ctx.fillRect(0, 0, w, h);
89
+ if (current.stars) {
90
+ ctx.fillStyle = 'rgba(255,255,255,0.85)';
91
+ for (let i = 0; i < 90; i += 1) ctx.fillRect(hashUnit(`star${i}x`) * w, hashUnit(`star${i}y`) * h * 0.55, 1.2, 1.2);
92
+ }
94
93
  });
95
- scene.fog = new THREE.Fog(0xe7efee, 70, 170);
96
- scene.add(new THREE.HemisphereLight(0xf6f9ff, 0xd8ccb2, 1.25));
97
- const sun = new THREE.DirectionalLight(0xfff0d8, 2.4);
98
- sun.position.set(30, 55, 22);
94
+ scene.fog = new THREE.Fog(sky.fog, 70, 170);
95
+ const hemisphere = new THREE.HemisphereLight(0xf4f8ff, 0xd8ccb2, sky.hemi);
96
+ scene.add(hemisphere);
97
+ const sun = new THREE.DirectionalLight(sky.sun, sky.sunIntensity);
98
+ sun.position.set(34, 58, 24);
99
99
  sun.castShadow = true;
100
100
  sun.shadow.mapSize.set(2048, 2048);
101
- Object.assign(sun.shadow.camera, { left: -45, right: 45, top: 45, bottom: -45, near: 1, far: 160 });
102
- sun.shadow.bias = -0.0005;
101
+ sun.shadow.radius = 4;
102
+ sun.shadow.bias = -0.0004;
103
+ sun.shadow.normalBias = 0.03;
104
+ Object.assign(sun.shadow.camera, { left: -48, right: 48, top: 48, bottom: -48, near: 1, far: 170 });
103
105
  scene.add(sun);
106
+ const fill = new THREE.DirectionalLight(0xdfe9ff, 0.6);
107
+ fill.position.set(-40, 30, -30);
108
+ scene.add(fill);
109
+ // Lit windows and street lamps follow the sky (brightest at night); their meaning does not change.
110
+ const litMaterials = new Set();
111
+ const lampMaterials = new Set();
112
+ function applySky() {
113
+ sky = skyAt(new Date().getHours(), skyMode);
114
+ scene.background = skyTexture(sky);
115
+ scene.fog.color.set(sky.fog);
116
+ hemisphere.intensity = sky.hemi;
117
+ sun.color.set(sky.sun);
118
+ sun.intensity = sky.sunIntensity;
119
+ renderer.toneMappingExposure = sky.exposure;
120
+ for (const mat of litMaterials) mat.emissiveIntensity = sky.glow;
121
+ for (const mat of lampMaterials) mat.emissiveIntensity = sky.lamps ? 1.8 : 0;
122
+ }
123
+ applySky();
124
+ const maxAnisotropy = renderer.capabilities.getMaxAnisotropy?.() ?? 1;
104
125
 
105
126
  const camera = new THREE.PerspectiveCamera(40, 1, 0.5, 600);
106
127
  const controls = new MapControls(camera, renderer.domElement);
@@ -122,6 +143,150 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
122
143
  };
123
144
  const box = (w, h, d, color, at = {}, options = {}) => mesh(new THREE.BoxGeometry(w, h, d), material(color, options), { y: h / 2, ...at });
124
145
 
146
+ // Facades as in the world: windows drawn on the walls, glowing at dusk and night when the building is lit.
147
+ // Textures are shared; materials are made per lot, so fading one lot never fades another.
148
+ const facadeCache = new Map();
149
+ function facadeTiles(style, wall, lit) {
150
+ const key = `${style}|${new THREE.Color(wall).getHexString()}|${lit}`;
151
+ if (!facadeCache.has(key)) {
152
+ const make = (glowOnly) => {
153
+ const size = FACADE_SIZES[style] ?? 128;
154
+ const texture = canvasTexture(doc, size, size, (ctx, w, h) => drawFacade(ctx, w, h, style, wall, lit, glowOnly));
155
+ texture.wrapS = THREE.RepeatWrapping;
156
+ texture.wrapT = THREE.RepeatWrapping;
157
+ texture.anisotropy = maxAnisotropy;
158
+ return texture;
159
+ };
160
+ facadeCache.set(key, { map: make(false), glow: lit ? make(true) : null });
161
+ }
162
+ return facadeCache.get(key);
163
+ }
164
+ function facadeMaterial(style, wall, lit, span, h) {
165
+ const tiles = facadeTiles(style, wall, lit);
166
+ const [unitW, unitH] = FACADE_UNITS[style] ?? [1, 1];
167
+ const repeat = unitW === 0 ? [1, Math.max(1, Math.round(h / 1.1))] : [Math.max(1, Math.round(span / unitW)), Math.max(1, Math.round(h / unitH))];
168
+ const map = tiles.map.clone();
169
+ map.needsUpdate = true;
170
+ map.repeat.set(...repeat);
171
+ const mat = material(0xffffff, { map, roughness: 0.85 });
172
+ if (tiles.glow) {
173
+ const glow = tiles.glow.clone();
174
+ glow.needsUpdate = true;
175
+ glow.repeat.copy(map.repeat);
176
+ mat.emissive = new THREE.Color(0xffc867);
177
+ mat.emissiveMap = glow;
178
+ mat.emissiveIntensity = sky.glow;
179
+ litMaterials.add(mat);
180
+ }
181
+ return mat;
182
+ }
183
+ /** A block with facades on its four walls and a plain roof, standing on y = 0. */
184
+ function block(w, h, d, { style = 'office', wall = 0xe9e4da, lit = false, roof = 0xc9c5bb } = {}) {
185
+ const alongX = facadeMaterial(style, wall, lit, d, h);
186
+ const alongZ = facadeMaterial(style, wall, lit, w, h);
187
+ const top = material(roof, { roughness: 0.95 });
188
+ return mesh(new THREE.BoxGeometry(w, h, d), [alongX, alongX, top, top, alongZ, alongZ], { y: h / 2 });
189
+ }
190
+
191
+ // Trees and street lamps are many; like the world, they are instanced (scenery, no data).
192
+ function plantTrees(parent, placements) {
193
+ if (!placements.length) return;
194
+ const conifers = [];
195
+ const broadleaves = [];
196
+ for (const placement of placements) (hashUnit(`${placement[0]},${placement[1]}k`) < 0.42 ? conifers : broadleaves).push(placement);
197
+ const foliage = () => material(0xffffff, { roughness: 0.9, flatShading: true });
198
+ const trunks = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.1, 0.15, 0.8, 6), material(0x7d6249, { roughness: 1 }), placements.length);
199
+ const lower = new THREE.InstancedMesh(new THREE.ConeGeometry(0.78, 1.3, 7), foliage(), Math.max(conifers.length, 1));
200
+ const upper = new THREE.InstancedMesh(new THREE.ConeGeometry(0.55, 1.05, 7), foliage(), Math.max(conifers.length, 1));
201
+ const crowns = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(0.72, 0), foliage(), Math.max(broadleaves.length, 1));
202
+ const matrix = new THREE.Matrix4();
203
+ const rotation = new THREE.Quaternion();
204
+ const tint = new THREE.Color();
205
+ const up = new THREE.Vector3(0, 1, 0);
206
+ const place = (instanced, index, x, y, z, sx, sy, sz, spin) => {
207
+ rotation.setFromAxisAngle(up, spin);
208
+ matrix.compose(new THREE.Vector3(x, y, z), rotation, new THREE.Vector3(sx, sy, sz));
209
+ instanced.setMatrixAt(index, matrix);
210
+ };
211
+ placements.forEach(([x, z, scale], index) => place(trunks, index, x, 0.4 * scale, z, scale, scale, scale, 0));
212
+ conifers.forEach(([x, z, scale], index) => {
213
+ const spin = hashUnit(`${x}${z}s`) * Math.PI;
214
+ const tall = 0.9 + hashUnit(`${z}${x}t`) * 0.35;
215
+ place(lower, index, x, 1.15 * scale * tall, z, scale, scale * tall, scale, spin);
216
+ place(upper, index, x, 1.85 * scale * tall, z, scale, scale * tall, scale, spin);
217
+ const shade = tint.setHSL(0.37 + (hashUnit(`${x},${z}`) - 0.5) * 0.04, 0.34, 0.3 + hashUnit(`${z},${x}`) * 0.08);
218
+ lower.setColorAt(index, shade);
219
+ upper.setColorAt(index, shade.offsetHSL(0, 0, 0.04));
220
+ });
221
+ broadleaves.forEach(([x, z, scale], index) => {
222
+ const squash = 0.85 + hashUnit(`${x}${z}q`) * 0.25;
223
+ place(crowns, index, x, 1.25 * scale, z, scale * squash, scale, scale * (1.9 - squash), hashUnit(`${x}${z}s`) * Math.PI);
224
+ crowns.setColorAt(index, tint.setHSL(0.24 + hashUnit(`${x},${z}h`) * 0.08, 0.38, 0.4 + hashUnit(`${z},${x}l`) * 0.12));
225
+ });
226
+ lower.count = conifers.length;
227
+ upper.count = conifers.length;
228
+ crowns.count = broadleaves.length;
229
+ for (const instanced of [trunks, lower, upper, crowns]) {
230
+ instanced.castShadow = true;
231
+ instanced.receiveShadow = true;
232
+ parent.add(instanced);
233
+ }
234
+ }
235
+ function placeLamps(parent, placements) {
236
+ if (!placements.length) return;
237
+ const metal = material(0x3f4844, { roughness: 0.6, metalness: 0.4 });
238
+ const poles = new THREE.InstancedMesh(new THREE.CylinderGeometry(0.05, 0.07, 1.9, 6), metal, placements.length);
239
+ const heads = new THREE.InstancedMesh(new THREE.BoxGeometry(0.26, 0.08, 0.26), metal, placements.length);
240
+ const bulbMaterial = material(0xfff3d6, { emissive: new THREE.Color(0xffd27a), emissiveIntensity: sky.lamps ? 1.8 : 0 });
241
+ lampMaterials.add(bulbMaterial);
242
+ const bulbs = new THREE.InstancedMesh(new THREE.SphereGeometry(0.11, 10, 8), bulbMaterial, placements.length);
243
+ const matrix = new THREE.Matrix4();
244
+ placements.forEach(([x, z], index) => {
245
+ poles.setMatrixAt(index, matrix.makeTranslation(x, 0.95, z));
246
+ heads.setMatrixAt(index, matrix.makeTranslation(x, 1.92, z));
247
+ bulbs.setMatrixAt(index, matrix.makeTranslation(x, 1.82, z));
248
+ });
249
+ for (const instanced of [poles, heads, bulbs]) {
250
+ instanced.castShadow = instanced !== bulbs;
251
+ parent.add(instanced);
252
+ }
253
+ }
254
+ const dashTexture = canvasTexture(doc, 8, 64, (ctx, w, h) => {
255
+ ctx.clearRect(0, 0, w, h);
256
+ ctx.fillStyle = '#ffffff';
257
+ ctx.fillRect(1, 0, w - 2, h * 0.55);
258
+ });
259
+ dashTexture.wrapT = THREE.RepeatWrapping;
260
+ /** A street along z (or along x when `across`): sidewalks, asphalt, and a dashed centre line if wide. */
261
+ function streetStrip(parent, { x = 0, z = 0, width, length, across = false, centreLine = false }) {
262
+ const strip = (stripWidth, mat, y) => {
263
+ const surface = mesh(new THREE.PlaneGeometry(stripWidth, length), mat, { x, y, z, shadow: false });
264
+ surface.rotation.set(-Math.PI / 2, 0, across ? Math.PI / 2 : 0);
265
+ parent.add(surface);
266
+ };
267
+ strip(width + 0.7, material(0xe4dfd2, { roughness: 1 }), 0.025);
268
+ strip(width, material(0x8e938f, { roughness: 0.95 }), 0.04);
269
+ if (centreLine) {
270
+ const dashes = dashTexture.clone();
271
+ dashes.needsUpdate = true;
272
+ dashes.repeat.set(1, Math.max(1, Math.round(length / 1.6)));
273
+ strip(0.1, new THREE.MeshBasicMaterial({ map: dashes, transparent: true, opacity: 0.85, color: 0xf6f3e6 }), 0.055);
274
+ }
275
+ }
276
+ // A tarp, folded: vertical bands of light and shade on the blue sheet (the colour is the meaning).
277
+ const tarpTexture = canvasTexture(doc, 64, 64, (ctx, w, h) => {
278
+ ctx.fillStyle = '#ffffff';
279
+ ctx.fillRect(0, 0, w, h);
280
+ for (let i = 0; i < 8; i += 1) {
281
+ ctx.fillStyle = i % 2 ? 'rgba(0,0,0,0.06)' : 'rgba(255,255,255,0.0)';
282
+ ctx.fillRect((i * w) / 8, 0, w / 8, h);
283
+ }
284
+ ctx.fillStyle = 'rgba(0,0,0,0.12)';
285
+ ctx.fillRect(0, h * 0.48, w, 2);
286
+ });
287
+ tarpTexture.wrapS = THREE.RepeatWrapping;
288
+ tarpTexture.wrapT = THREE.RepeatWrapping;
289
+
125
290
  let root = null;
126
291
  let lots = new Map();
127
292
  let labels = [];
@@ -161,7 +326,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
161
326
  const overdue = GAP(site, 'review_overdue');
162
327
  const fade = (color) => (overdue ? new THREE.Color(color).lerp(new THREE.Color(0xb9b29a), 0.45) : new THREE.Color(color));
163
328
  const half = DISTRICT_STREETS.lot / 2;
164
- group.add(box(DISTRICT_STREETS.lot, 0.06, DISTRICT_STREETS.lot, overdue ? 0xc9c0a2 : 0xd9d4c4));
329
+ // The lot: a rounded plate, gravel on a site and lawn round a finished house (faded when overdue).
330
+ const groundColor = site.work.status === 'completed' ? 0xb7cf9f : 0xd9d2bf;
331
+ group.add(mesh(roundedPlate(DISTRICT_STREETS.lot, DISTRICT_STREETS.lot, 0.08, 0.32), material(fade(groundColor), { roughness: 1 })));
165
332
  const status = site.work.status;
166
333
  const building = status === 'in_progress' || status === 'waiting';
167
334
  if (status !== 'completed') {
@@ -181,34 +348,57 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
181
348
  if (building) {
182
349
  const floors = 1 + Math.floor(hash(`${site.task_id}f`) * 2);
183
350
  const height = 0.75 * floors;
184
- group.add(box(1.7, height, 1.5, fade(STATE_WALL[status]), { z: -0.25 }));
185
- // Scaffolding: a frame of edges around the building.
186
- const frame = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(2.0, height + 0.6, 1.8)), new THREE.LineBasicMaterial({ color: 0x8c8f8a }));
187
- frame.position.set(0, (height + 0.6) / 2, -0.25);
188
- group.add(frame);
351
+ // The building rising: walls with their windows (unlit, no one has moved in), concrete on top.
352
+ const core = block(1.7, height, 1.5, { style: hash(`${site.task_id}s`) < 0.5 ? 'office' : 'brick', wall: fade(STATE_WALL[status]).getHex(), roof: 0xa9a59b });
353
+ core.position.z = -0.25;
354
+ group.add(core);
355
+ // Scaffolding: poles at the corners and along the faces, a plank at each floor.
356
+ const steel = material(0x9a9c97, { roughness: 0.5, metalness: 0.5 });
357
+ const plank = material(0xb88f5a, { roughness: 0.9 });
358
+ const scaffoldH = height + 0.55;
359
+ for (const x of [-1.0, -0.33, 0.33, 1.0]) {
360
+ for (const z of [-1.15, 0.65]) group.add(mesh(new THREE.CylinderGeometry(0.03, 0.03, scaffoldH, 5), steel, { x, y: scaffoldH / 2, z }));
361
+ }
362
+ // Planks only where work goes on; a held site is wrapped in its tarp.
363
+ for (let f = 1; status === 'in_progress' && f <= floors; f += 1) {
364
+ for (const z of [-1.15, 0.65]) group.add(mesh(new THREE.BoxGeometry(2.05, 0.04, 0.24), plank, { y: f * 0.75, z }));
365
+ for (const x of [-1.0, 1.0]) group.add(mesh(new THREE.BoxGeometry(0.24, 0.04, 1.8), plank, { x, y: f * 0.75, z: -0.25 }));
366
+ }
189
367
  if (status === 'in_progress') {
190
- const net = mesh(new THREE.PlaneGeometry(2.0, height + 0.5), material(0x4f9a5c, { transparent: true, opacity: 0.55, side: THREE.DoubleSide }), { y: (height + 0.5) / 2, z: 0.66, shadow: false });
368
+ const net = mesh(new THREE.PlaneGeometry(2.0, scaffoldH - 0.05), material(0x4f9a5c, { transparent: true, opacity: 0.55, side: THREE.DoubleSide }), { y: scaffoldH / 2, z: 0.7, shadow: false });
191
369
  group.add(net);
192
- // A crane over a site being worked.
193
- group.add(mesh(new THREE.BoxGeometry(0.12, 3.6, 0.12), material(0xe0a526), { x: 0.9, y: 1.8, z: -0.9 }));
194
- group.add(mesh(new THREE.BoxGeometry(2.2, 0.1, 0.1), material(0xe0a526), { x: 0.2, y: 3.5, z: -0.9 }));
370
+ // A tower crane over a site being worked: a mast, a jib and its counterweight.
371
+ const yellow = material(0xe0a526, { roughness: 0.6 });
372
+ group.add(mesh(new THREE.BoxGeometry(0.14, 3.8, 0.14), yellow, { x: 0.95, y: 1.9, z: -1.05 }));
373
+ group.add(mesh(new THREE.BoxGeometry(2.6, 0.1, 0.12), yellow, { x: 0.35, y: 3.7, z: -1.05 }));
374
+ group.add(mesh(new THREE.BoxGeometry(0.34, 0.3, 0.3), material(0x6b6b66), { x: 1.55, y: 3.55, z: -1.05 }));
375
+ group.add(mesh(new THREE.CylinderGeometry(0.008, 0.008, 1.4, 4), material(0x333333), { x: -0.7, y: 3.0, z: -1.05, shadow: false }));
195
376
  } else {
196
- // Waiting: a blue tarp over the building, as on a site where work is held.
197
- group.add(box(1.9, height + 0.4, 1.7, fade(0x3f78b5), { z: -0.25 }, { transparent: true, opacity: 0.9, roughness: 0.6 }));
377
+ // Waiting: a blue tarp over the building and its scaffold, tied down, as on a site where work is held.
378
+ const tarp = tarpTexture.clone();
379
+ tarp.needsUpdate = true;
380
+ tarp.repeat.set(2, 1);
381
+ group.add(box(2.15, scaffoldH, 1.95, fade(0x4f93db), { z: -0.25 }, { map: tarp, transparent: true, opacity: 0.95, roughness: 0.5, emissive: fade(0x1d4f8a), emissiveIntensity: 0.35 }));
382
+ const rope = material(0xf2efe6);
383
+ for (const x of [-1.08, 1.08]) group.add(mesh(new THREE.BoxGeometry(0.02, 0.02, 2.0), rope, { x, y: scaffoldH * 0.55, z: -0.25, shadow: false }));
198
384
  }
199
385
  }
200
386
  if (status === 'completed') {
201
- group.add(box(1.6, 1.0, 1.4, 0xf2ebdd, { z: -0.3 }));
202
- const roof = mesh(new THREE.ConeGeometry(1.25, 0.7, 4), material(0xb5654a), { y: 1.35, z: -0.3 });
203
- roof.rotation.y = Math.PI / 4;
387
+ // A finished house: plastered walls with lit windows, a gable roof, a door to the street.
388
+ const house = block(1.7, 1.05, 1.4, { style: 'house', wall: 0xf2ebdd, lit: true, roof: 0xd8d0c2 });
389
+ house.position.z = -0.3;
390
+ group.add(house);
391
+ const roof = mesh(gableRoof(1.7, 1.4, 0.7), material(0xb5654a, { roughness: 0.75 }), { y: 1.05, z: -0.3 });
392
+ roof.rotation.y = Math.PI / 2;
204
393
  group.add(roof);
205
- group.add(mesh(new THREE.BoxGeometry(0.5, 0.36, 0.04), material(0xffd98c, { emissive: new THREE.Color(0xffc867), emissiveIntensity: 0.9 }), { y: 0.55, z: 0.41, shadow: false }));
206
- group.add(mesh(new THREE.BoxGeometry(0.2, 0.5, 0.2), material(0x9c7a62), { x: 0.4, y: 1.5, z: -0.6 }));
207
- group.userData.chimney = new THREE.Vector3(0.4, 1.8, -0.6);
394
+ group.add(mesh(new THREE.BoxGeometry(0.34, 0.6, 0.04), material(0x8a5a3c), { y: 0.3, z: 0.41 }));
395
+ group.add(mesh(new THREE.BoxGeometry(0.2, 0.5, 0.2), material(0x9c7a62), { x: 0.45, y: 1.6, z: -0.6 }));
396
+ group.userData.chimney = new THREE.Vector3(0.45, 1.9, -0.6);
208
397
  }
209
398
  // A path to the street only when the record links its source.
210
399
  if (!GAP(site, 'source_unlinked') && !GAP(site, 'outcome_unlinked')) {
211
- group.add(mesh(new THREE.BoxGeometry(0.7, 0.04, 1.0), material(0xbdb7aa), { y: 0.05, z: half + 0.45, shadow: false }));
400
+ const stones = material(0xcfc8b8, { roughness: 1 });
401
+ for (let i = 0; i < 3; i += 1) group.add(mesh(roundedPlate(0.6, 0.26, 0.03, 0.08), stones, { x: (i % 2 ? 0.06 : -0.06), y: 0.04, z: half + 0.18 + i * 0.32, shadow: false }));
212
402
  }
213
403
  // A blueprint stands where the completion condition is, with no record of what was made.
214
404
  if (GAP(site, 'outcome_unlinked')) {
@@ -262,6 +452,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
262
452
  // --- the district -------------------------------------------------------------------------------
263
453
  function clear() {
264
454
  if (root) scene.remove(root);
455
+ litMaterials.clear();
456
+ lampMaterials.clear();
265
457
  for (const label of labels) label.node.remove();
266
458
  labels = [];
267
459
  lots = new Map();
@@ -284,79 +476,100 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
284
476
  const L = layout.length;
285
477
  const gateZ = L / 2 + 4;
286
478
  const hallZ = -L / 2 - 7;
287
- // Land, the district's paving, the main street and the back streets.
288
- const land = mesh(new THREE.PlaneGeometry(260, 260), material(0xa9c595, { roughness: 1 }), { shadow: false });
479
+ // Land as in the world: gentle, non-repeating shades of grass (scenery).
480
+ const grass = { low: new THREE.Color(0x9fbf8c), high: new THREE.Color(0xd3e4bd), warm: new THREE.Color(0xd9d6a6) };
481
+ const land = mesh(paintVertices(new THREE.CircleGeometry(150, 96, 0, Math.PI * 2), (color, x, y) => {
482
+ color.copy(grass.low).lerp(grass.high, valueNoise(x / 16, y / 16, 'dland') * 0.7 + valueNoise(x / 4.5, y / 4.5, 'dtuft') * 0.3);
483
+ if (valueNoise(x / 22, y / 22, 'ddry') > 0.68) color.lerp(grass.warm, 0.35);
484
+ }), material(0xffffff, { vertexColors: true, roughness: 1 }), { shadow: false });
289
485
  land.rotation.x = -Math.PI / 2;
486
+ land.receiveShadow = true;
290
487
  root.add(land);
291
- const paving = mesh(new THREE.PlaneGeometry(26, L + 22), material(0xe9e4d6, { roughness: 1 }), { y: 0.01, z: (gateZ + hallZ) / 2, shadow: false });
292
- paving.rotation.x = -Math.PI / 2;
293
- root.add(paving);
294
- const asphalt = material(0x8e938f, { roughness: 0.95 });
295
- const street = (x, width, from, to) => {
296
- const strip = mesh(new THREE.PlaneGeometry(width, Math.abs(to - from)), asphalt, { x, y: 0.02, z: (from + to) / 2, shadow: false });
297
- strip.rotation.x = -Math.PI / 2;
298
- root.add(strip);
299
- };
300
- street(0, DISTRICT_STREETS.main * 2, gateZ + 8, hallZ + 3);
301
- street(-DISTRICT_STREETS.backStreet, 2, L / 2 + 1, -L / 2 - 1);
302
- street(DISTRICT_STREETS.backStreet, 2, L / 2 + 1, -L / 2 - 1);
488
+ // The district's paving: a raised plate with a kerb, from the gate to the hall.
489
+ const pavingDepth = gateZ - hallZ + 14;
490
+ root.add(mesh(roundedPlate(28, pavingDepth, 0.04, 2.2), material(0xd4cec0, { roughness: 1 }), { z: (gateZ + hallZ) / 2, shadow: false }));
491
+ root.add(mesh(roundedPlate(27, pavingDepth - 1, 0.06, 1.8), material(0xebe6d8, { roughness: 1 }), { z: (gateZ + hallZ) / 2, shadow: false }));
492
+ const lift = new THREE.Group();
493
+ lift.position.y = 0.17;
494
+ root.add(lift);
495
+ streetStrip(lift, { x: 0, z: (gateZ + 8 + hallZ + 3) / 2, width: DISTRICT_STREETS.main * 2, length: gateZ + 8 - (hallZ + 3), centreLine: true });
496
+ streetStrip(lift, { x: -DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2 });
497
+ streetStrip(lift, { x: DISTRICT_STREETS.backStreet, z: 0, width: 1.6, length: L + 2 });
303
498
  // A cross street between the blocks, and at the hall end of each block a sign saying what its work is for.
304
499
  const crossWidth = DISTRICT_STREETS.backLot * 2 + 4;
305
500
  layout.streets.forEach((block, index) => {
306
501
  if (index < layout.streets.length - 1) {
307
- const strip = mesh(new THREE.PlaneGeometry(crossWidth, DISTRICT_STREETS.cross * 0.7), asphalt, { y: 0.02, z: block.z_to + DISTRICT_STREETS.cross / 2, shadow: false });
308
- strip.rotation.x = -Math.PI / 2;
309
- root.add(strip);
502
+ streetStrip(lift, { x: 0, z: block.z_to + DISTRICT_STREETS.cross / 2, width: DISTRICT_STREETS.cross * 0.62, length: crossWidth, across: true });
310
503
  }
311
504
  const signZ = block.z_from - (index === 0 ? 0.9 : DISTRICT_STREETS.cross / 2);
312
- root.add(mesh(new THREE.CylinderGeometry(0.07, 0.07, 2.4, 6), material(0x4a4a4a), { x: -DISTRICT_STREETS.main - 0.5, y: 1.2, z: signZ }));
313
- root.add(mesh(new THREE.BoxGeometry(1.9, 0.5, 0.08), material(block.label ? 0x2f6e4f : 0xbfb8a8), { x: -DISTRICT_STREETS.main - 0.5, y: 2.3, z: signZ }));
505
+ root.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.5, 8), material(0x3f4844, { roughness: 0.6, metalness: 0.4 }), { x: -DISTRICT_STREETS.main - 0.5, y: 1.25, z: signZ }));
506
+ root.add(mesh(new THREE.BoxGeometry(1.1, 0.34, 0.05), material(block.label ? 0x2f6e4f : 0xbfb8a8, { roughness: 0.6 }), { x: -DISTRICT_STREETS.main - 0.5, y: 2.2, z: signZ }));
314
507
  const text = block.label ? `${block.label}(${block.count}件)` : `未分類の空き地:何のための仕事か未記録(${block.count}件)`;
315
508
  addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
316
509
  });
317
- // Trees and lamps along the main street, between the lots (scenery).
318
- const trunk = material(0x7d6249);
319
- const crown = material(0x6f9a5c, { flatShading: true });
510
+ // Trees along the main street and lamps between them; a belt of trees round the district (scenery).
511
+ const trees = [];
512
+ const lamps = [];
320
513
  for (let z = -L / 2; z <= L / 2; z += DISTRICT_STREETS.row) {
321
- for (const x of [-2.4, 2.4]) {
322
- const at = z + DISTRICT_STREETS.row / 2;
323
- if (at > L / 2) continue;
324
- root.add(mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.8, 6), trunk, { x, y: 0.4, z: at }));
325
- root.add(mesh(new THREE.IcosahedronGeometry(0.55, 0), crown, { x, y: 1.15, z: at }));
326
- }
514
+ const at = z + DISTRICT_STREETS.row / 2;
515
+ if (at > L / 2) continue;
516
+ for (const x of [-2.45, 2.45]) trees.push([x, at, 0.62]);
517
+ for (const x of [-2.45, 2.45]) lamps.push([x, at + DISTRICT_STREETS.row / 2]);
327
518
  }
328
- // The gate.
329
- const stone = material(0xe2dccf);
330
- root.add(box(0.7, 4.2, 0.7, 0xe2dccf, { x: -3.2, z: gateZ }));
331
- root.add(box(0.7, 4.2, 0.7, 0xe2dccf, { x: 3.2, z: gateZ }));
332
- root.add(mesh(new THREE.BoxGeometry(7.6, 0.6, 0.9), stone, { y: 4.4, z: gateZ }));
519
+ for (let i = 0; i < 90; i += 1) {
520
+ const angle = (i / 90) * Math.PI * 2 + hashUnit(`belt${i}a`) * 0.05;
521
+ const rx = 17 + hashUnit(`belt${i}r`) * 9;
522
+ const rz = pavingDepth / 2 + 3 + hashUnit(`belt${i}z`) * 8;
523
+ trees.push([Math.cos(angle) * rx, (gateZ + hallZ) / 2 + Math.sin(angle) * rz, 0.8 + hashUnit(`belt${i}s`) * 0.6]);
524
+ }
525
+ plantTrees(root, trees.filter(([x, z]) => !(Math.abs(x) < 4.2 && z > gateZ - 1)));
526
+ placeLamps(lift, lamps);
527
+ // The gate: stone pillars on plinths, a lintel with a cornice, lamps on its pillars.
528
+ const stone = material(0xe2dccf, { roughness: 0.9 });
529
+ for (const x of [-3.3, 3.3]) {
530
+ root.add(mesh(roundedPlate(1.2, 1.2, 0.3, 0.12), stone, { x, z: gateZ }));
531
+ root.add(mesh(new THREE.BoxGeometry(0.72, 4.0, 0.72), stone, { x, y: 2.3, z: gateZ }));
532
+ root.add(mesh(new THREE.BoxGeometry(0.9, 0.18, 0.9), stone, { x, y: 4.35, z: gateZ }));
533
+ }
534
+ root.add(mesh(new THREE.BoxGeometry(7.8, 0.55, 0.95), stone, { y: 4.65, z: gateZ }));
535
+ root.add(mesh(new THREE.BoxGeometry(8.2, 0.16, 1.1), material(0xcfc8ba, { roughness: 0.9 }), { y: 5.0, z: gateZ }));
536
+ placeLamps(root, [[-3.3, gateZ + 0.6], [3.3, gateZ + 0.6]]);
333
537
  addLabel(`${business.name}の区画(入口)`, new THREE.Vector3(0, 5.2, gateZ), 'is-city', { priority: 1 });
334
538
  // The hall: what this business is for, and the stones of its policies in front of it.
335
- root.add(box(8, 3.6, 4.4, 0xeee8dc, { z: hallZ }));
336
- root.add(mesh(new THREE.BoxGeometry(8.6, 0.3, 5.0), stone, { y: 3.75, z: hallZ }));
337
- for (let i = 0; i < 6; i += 1) root.add(mesh(new THREE.CylinderGeometry(0.16, 0.18, 3.0, 10), material(0xf7f4ee), { x: -3.1 + i * 1.24, y: 1.5, z: hallZ + 2.5 }));
338
- const pediment = mesh(new THREE.ConeGeometry(4.9, 1.2, 4), material(0xf7f4ee), { y: 4.5, z: hallZ });
339
- pediment.rotation.y = Math.PI / 4;
340
- pediment.scale.z = 0.6;
539
+ // A civic building as in the world: steps, a colonnade, tall lit windows, a pediment.
540
+ for (let step = 0; step < 3; step += 1) root.add(mesh(roundedPlate(9.6 - step * 0.5, 6.2 - step * 0.4, 0.1, 0.25), stone, { y: step * 0.18, z: hallZ + 0.2 }));
541
+ const hall = block(8, 3.4, 4.2, { style: 'civic', wall: 0xeee8dc, lit: true, roof: 0xd9d2c4 });
542
+ hall.position.set(0, 0.54, hallZ);
543
+ root.add(hall);
544
+ root.add(mesh(new THREE.BoxGeometry(8.6, 0.3, 4.9), stone, { y: 4.08, z: hallZ }));
545
+ for (let i = 0; i < 6; i += 1) {
546
+ const x = -3.1 + i * 1.24;
547
+ root.add(mesh(new THREE.CylinderGeometry(0.16, 0.19, 3.0, 14), material(0xf7f4ee, { roughness: 0.7 }), { x, y: 2.04, z: hallZ + 2.45 }));
548
+ root.add(mesh(new THREE.BoxGeometry(0.46, 0.12, 0.46), stone, { x, y: 3.6, z: hallZ + 2.45 }));
549
+ }
550
+ const pediment = mesh(gableRoof(8.6, 4.9, 1.15), material(0xf7f4ee, { roughness: 0.8 }), { y: 4.23, z: hallZ });
341
551
  root.add(pediment);
342
- root.add(box(0.4, 0.9, 0.4, 0x9c7a62, { x: 2.8, y: 3.9, z: hallZ - 1.2 }));
343
- const hallChimney = new THREE.Vector3(2.8, 4.8, hallZ - 1.2);
552
+ root.add(box(0.4, 0.9, 0.4, 0x9c7a62, { x: 2.8, y: 4.3, z: hallZ - 1.2 }));
553
+ const hallChimney = new THREE.Vector3(2.8, 5.2, hallZ - 1.2);
344
554
  const purposeText = work?.status === 'ok' ? (work.purpose.text ?? '目的は未登録') : '目的を読めません';
345
555
  addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
346
- const plaza = mesh(new THREE.CircleGeometry(3.6, 40), material(0xf1ece1), { y: 0.03, z: hallZ + 5.4, shadow: false });
556
+ const plaza = mesh(new THREE.CircleGeometry(3.6, 48), material(0xf1ece1, { roughness: 1 }), { y: 0.2, z: hallZ + 5.4, shadow: false });
347
557
  plaza.rotation.x = -Math.PI / 2;
348
558
  root.add(plaza);
559
+ const ring = mesh(new THREE.RingGeometry(3.6, 3.85, 48), material(0xcfc8b8, { roughness: 1 }), { y: 0.21, z: hallZ + 5.4, shadow: false });
560
+ ring.rotation.x = -Math.PI / 2;
561
+ root.add(ring);
349
562
  const policies = work?.status === 'ok' ? work.purpose.policies.slice(0, 8) : [];
350
563
  policies.forEach((policy, index) => {
351
564
  const angle = Math.PI * (0.15 + (0.7 * index) / Math.max(policies.length - 1, 1));
352
565
  const x = Math.cos(angle) * 2.8;
353
566
  const z = hallZ + 5.4 + Math.sin(angle) * 1.6;
354
567
  if (policy.link === 'recorded') {
355
- root.add(box(0.5, 1.2, 0.22, 0xa8a49a, { x, z }));
568
+ root.add(box(0.5, 1.2, 0.22, 0xa8a49a, { x, y: 0.8, z }, { roughness: 0.9 }));
356
569
  } else {
357
570
  const outline = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(0.5, 1.2, 0.22)), new THREE.LineDashedMaterial({ color: 0x5b4a8a, dashSize: 0.12, gapSize: 0.08 }));
358
571
  outline.computeLineDistances();
359
- outline.position.set(x, 0.6, z);
572
+ outline.position.set(x, 0.8, z);
360
573
  root.add(outline);
361
574
  }
362
575
  const title = policy.title.length > 18 ? `${policy.title.slice(0, 17)}…` : policy.title;
@@ -381,7 +594,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
381
594
  for (const site of shown) {
382
595
  const lot = layout.lots[site.task_id];
383
596
  const group = lotGroup(site);
384
- group.position.set(lot.x, 0, lot.z);
597
+ group.position.set(lot.x, 0.17, lot.z);
385
598
  group.rotation.y = lot.facing > 0 ? Math.PI / 2 : -Math.PI / 2;
386
599
  group.userData = { kind: 'site', site, chimney: group.userData.chimney ?? null };
387
600
  root.add(group);
@@ -415,6 +628,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
415
628
  }
416
629
  }
417
630
  // Fog when the work could not be read: the district cannot be seen, which is not empty.
631
+ scene.fog.color.set(sky.fog);
418
632
  scene.fog.near = readable ? 70 : 6;
419
633
  scene.fog.far = readable ? 170 : 34;
420
634
  notice.hidden = readable;
@@ -489,7 +703,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
489
703
  selectionRing.visible = false;
490
704
  return;
491
705
  }
492
- selectionRing.position.set(group.position.x, 0.09, group.position.z);
706
+ selectionRing.position.set(group.position.x, 0.3, group.position.z);
493
707
  selectionRing.visible = true;
494
708
  const at = group.position.clone();
495
709
  fly(new THREE.Vector3(at.x, 0, at.z), new THREE.Vector3(at.x + (at.x > 0 ? 9 : -9), 11, at.z + 12));
@@ -574,7 +788,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
574
788
  const forward = travel < 1;
575
789
  walker.position.set(x, 0, forward ? from + travel * span : to - (travel - 1) * span);
576
790
  walker.rotation.y = forward ? 0 : Math.PI;
577
- walker.position.y = Math.abs(Math.sin(seconds * 8 * speed)) * 0.04;
791
+ walker.position.y = 0.2 + Math.abs(Math.sin(seconds * 8 * speed)) * 0.04;
578
792
  }
579
793
  for (const puff of puffs) {
580
794
  const cycle = (seconds * 0.25 + puff.userData.phase) % 1;
@@ -613,6 +827,11 @@ export function createDistrictView({ doc, stage, reducedMotion = false, onPick,
613
827
  hide() {
614
828
  setVisible(false);
615
829
  },
830
+ /** The sky: the same modes as the world ('auto' follows the hour). */
831
+ setSky(mode) {
832
+ skyMode = mode;
833
+ applySky();
834
+ },
616
835
  isOpen: () => open,
617
836
  /** Back to the view from the gate, keeping the district. */
618
837
  home() {
@@ -0,0 +1,208 @@
1
+ /*
2
+ * Scenery shared by the world and the inside of a district: textures, value noise, facades, and the
3
+ * plate and roof shapes. None of it carries data; it only gives both scales the same look.
4
+ */
5
+
6
+ import { THREE } from './world-vendor.js';
7
+
8
+ export function hashUnit(text) {
9
+ let h = 2166136261;
10
+ for (let i = 0; i < text.length; i += 1) {
11
+ h ^= text.charCodeAt(i);
12
+ h = Math.imul(h, 16777619);
13
+ }
14
+ return ((h >>> 0) % 10000) / 10000;
15
+ }
16
+
17
+ export function canvasTexture(doc, width, height, draw) {
18
+ const canvas = doc.createElement('canvas');
19
+ canvas.width = width;
20
+ canvas.height = height;
21
+ draw(canvas.getContext('2d'), width, height);
22
+ const texture = new THREE.CanvasTexture(canvas);
23
+ texture.colorSpace = THREE.SRGBColorSpace;
24
+ return texture;
25
+ }
26
+
27
+ /** Smooth value noise in [0, 1]; used only to shade scenery (ground, sea, mountains) without repeating textures. */
28
+ export function valueNoise(x, z, seed = 'n') {
29
+ const xi = Math.floor(x);
30
+ const zi = Math.floor(z);
31
+ const fx = x - xi;
32
+ const fz = z - zi;
33
+ const sx = fx * fx * (3 - 2 * fx);
34
+ const sz = fz * fz * (3 - 2 * fz);
35
+ const at = (i, j) => hashUnit(`${seed}${i},${j}`);
36
+ const near = at(xi, zi) + (at(xi + 1, zi) - at(xi, zi)) * sx;
37
+ const far = at(xi, zi + 1) + (at(xi + 1, zi + 1) - at(xi, zi + 1)) * sx;
38
+ return near + (far - near) * sz;
39
+ }
40
+
41
+ /** Colours a geometry per vertex from its position (x, y, z before any rotation). */
42
+ export function paintVertices(geometry, colorAt) {
43
+ const position = geometry.attributes.position;
44
+ const colors = new Float32Array(position.count * 3);
45
+ const color = new THREE.Color();
46
+ for (let i = 0; i < position.count; i += 1) {
47
+ colorAt(color, position.getX(i), position.getY(i), position.getZ(i));
48
+ colors[i * 3] = color.r;
49
+ colors[i * 3 + 1] = color.g;
50
+ colors[i * 3 + 2] = color.b;
51
+ }
52
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
53
+ return geometry;
54
+ }
55
+
56
+ /*
57
+ * Facades: one tile of a wall, drawn for a style and a wall colour. Windows are on the walls only (roofs
58
+ * get a plain material). Which panes are lit is fixed per pane, so the glow map matches the colour map;
59
+ * whether a building has lit windows at all still means "moved in the last 30 days".
60
+ */
61
+ export const FACADE_UNITS = Object.freeze({ glass: [2.0, 2.3], office: [3.0, 2.0], brick: [1.3, 1.35], civic: [0.85, 1.5], research: [0.9, 1.2], house: [0, 0] });
62
+
63
+ /** Tile size in pixels: glass and office tiles hold several floors so lit panes do not repeat as a pattern. */
64
+ export const FACADE_SIZES = Object.freeze({ glass: 256, office: 256 });
65
+
66
+ export function drawFacade(ctx, w, h, style, wall, lit, glowOnly) {
67
+ const wallColor = `#${new THREE.Color(wall).getHexString()}`;
68
+ const dark = `#${new THREE.Color(wall).multiplyScalar(0.72).getHexString()}`;
69
+ const light = `#${new THREE.Color(wall).lerp(new THREE.Color(0xffffff), 0.35).getHexString()}`;
70
+ ctx.fillStyle = glowOnly ? '#000000' : wallColor;
71
+ ctx.fillRect(0, 0, w, h);
72
+ const pane = (x, y, pw, ph, index) => {
73
+ const on = lit && hashUnit(`${style}pane${index}x`) < 0.55;
74
+ if (glowOnly) {
75
+ ctx.fillStyle = on ? '#ffffff' : '#000000';
76
+ ctx.fillRect(x, y, pw, ph);
77
+ return;
78
+ }
79
+ if (on) {
80
+ ctx.fillStyle = '#ffd98c';
81
+ ctx.fillRect(x, y, pw, ph);
82
+ ctx.fillStyle = 'rgba(255,244,214,0.55)';
83
+ ctx.fillRect(x, y, pw, ph * 0.3);
84
+ } else {
85
+ const glass = ctx.createLinearGradient(x, y, x + pw, y + ph);
86
+ glass.addColorStop(0, '#9fb3c4');
87
+ glass.addColorStop(0.5, '#56687a');
88
+ glass.addColorStop(1, '#3f4f60');
89
+ ctx.fillStyle = glass;
90
+ ctx.fillRect(x, y, pw, ph);
91
+ }
92
+ };
93
+ if (style === 'glass') {
94
+ // A curtain wall: four floors of four panes, thin mullions, a floor band under each floor.
95
+ const cw = w / 4;
96
+ const rh = h / 4;
97
+ for (let r = 0; r < 4; r += 1) {
98
+ if (!glowOnly) {
99
+ ctx.fillStyle = dark;
100
+ ctx.fillRect(0, r * rh + rh - 7, w, 7);
101
+ }
102
+ for (let c = 0; c < 4; c += 1) pane(c * cw + 4, r * rh + 5, cw - 8, rh - 15, r * 4 + c);
103
+ }
104
+ if (!glowOnly) {
105
+ ctx.fillStyle = light;
106
+ for (let c = 0; c <= 4; c += 1) ctx.fillRect(c * cw - 2, 0, 4, h);
107
+ }
108
+ } else if (style === 'office') {
109
+ // Ribbon windows: two floors, each a long band split by mullions over a concrete spandrel.
110
+ const rh = h / 2;
111
+ for (let r = 0; r < 2; r += 1) {
112
+ if (!glowOnly) {
113
+ ctx.fillStyle = light;
114
+ ctx.fillRect(0, r * rh, w, 22);
115
+ ctx.fillStyle = dark;
116
+ ctx.fillRect(0, r * rh + rh - 10, w, 6);
117
+ }
118
+ for (let c = 0; c < 8; c += 1) pane(4 + c * (w / 8), r * rh + 34, w / 8 - 6, rh - 54, r * 8 + c);
119
+ }
120
+ } else if (style === 'brick') {
121
+ if (!glowOnly) {
122
+ ctx.strokeStyle = 'rgba(0,0,0,0.07)';
123
+ ctx.lineWidth = 2;
124
+ for (let y = 16; y < h; y += 16) {
125
+ ctx.beginPath();
126
+ ctx.moveTo(0, y);
127
+ ctx.lineTo(w, y);
128
+ ctx.stroke();
129
+ }
130
+ }
131
+ for (let c = 0; c < 2; c += 1) {
132
+ const x = 18 + c * 58;
133
+ if (!glowOnly) {
134
+ ctx.fillStyle = '#f5f1e8';
135
+ ctx.fillRect(x - 5, 22, 44, 82);
136
+ }
137
+ pane(x, 26, 34, 74, c);
138
+ if (!glowOnly) {
139
+ ctx.fillStyle = '#f5f1e8';
140
+ ctx.fillRect(x + 15, 26, 4, 74);
141
+ ctx.fillRect(x, 58, 34, 4);
142
+ }
143
+ }
144
+ } else if (style === 'civic') {
145
+ // Pilasters and tall windows.
146
+ if (!glowOnly) {
147
+ ctx.fillStyle = light;
148
+ ctx.fillRect(0, 0, 18, h);
149
+ ctx.fillRect(w - 18, 0, 18, h);
150
+ }
151
+ pane(30, 14, 68, 96, 0);
152
+ if (!glowOnly) {
153
+ ctx.fillStyle = light;
154
+ ctx.fillRect(62, 14, 4, 96);
155
+ }
156
+ } else if (style === 'research') {
157
+ for (let c = 0; c < 2; c += 1) pane(20 + c * 60, 18, 28, 90, c);
158
+ } else if (style === 'house') {
159
+ // A plastered wall with two framed windows (doors are separate meshes).
160
+ for (let c = 0; c < 2; c += 1) {
161
+ const x = 22 + c * 52;
162
+ if (!glowOnly) {
163
+ ctx.fillStyle = '#ffffff';
164
+ ctx.fillRect(x - 4, 36, 40, 50);
165
+ }
166
+ pane(x, 40, 32, 42, c);
167
+ if (!glowOnly) {
168
+ ctx.fillStyle = '#ffffff';
169
+ ctx.fillRect(x + 14, 40, 4, 42);
170
+ ctx.fillStyle = dark;
171
+ ctx.fillRect(x - 6, 86, 44, 5);
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ export function roundedPlate(width, depth, height, radius) {
178
+ const shape = new THREE.Shape();
179
+ const w = width / 2;
180
+ const d = depth / 2;
181
+ shape.moveTo(-w + radius, -d);
182
+ shape.lineTo(w - radius, -d);
183
+ shape.quadraticCurveTo(w, -d, w, -d + radius);
184
+ shape.lineTo(w, d - radius);
185
+ shape.quadraticCurveTo(w, d, w - radius, d);
186
+ shape.lineTo(-w + radius, d);
187
+ shape.quadraticCurveTo(-w, d, -w, d - radius);
188
+ shape.lineTo(-w, -d + radius);
189
+ shape.quadraticCurveTo(-w, -d, -w + radius, -d);
190
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: true, bevelThickness: 0.12, bevelSize: 0.12, bevelSegments: 2 });
191
+ geometry.rotateX(-Math.PI / 2);
192
+ return geometry;
193
+ }
194
+
195
+ export function gableRoof(width, depth, rise) {
196
+ const shape = new THREE.Shape();
197
+ shape.moveTo(-width / 2 - 0.1, 0);
198
+ shape.lineTo(width / 2 + 0.1, 0);
199
+ shape.lineTo(0, rise);
200
+ shape.lineTo(-width / 2 - 0.1, 0);
201
+ const geometry = new THREE.ExtrudeGeometry(shape, { depth: depth + 0.2, bevelEnabled: false });
202
+ geometry.translate(0, 0, -(depth + 0.2) / 2);
203
+ return geometry;
204
+ }
205
+
206
+ export function muted(color, amount) {
207
+ return new THREE.Color(color).lerp(new THREE.Color(0xb9c2bb), amount);
208
+ }
@@ -132,6 +132,60 @@
132
132
  font-size: 13px;
133
133
  }
134
134
 
135
+ /* A city's name is a way into its district (AC-20). */
136
+ .bb-world-label.is-enterable {
137
+ pointer-events: auto;
138
+ cursor: pointer;
139
+ }
140
+
141
+ .bb-world-label.is-enterable::after {
142
+ content: ' ›';
143
+ color: var(--bb-color-muted);
144
+ }
145
+
146
+ .bb-world-label.is-enterable:hover,
147
+ .bb-world-label.is-enterable.is-hover {
148
+ background: #1f5f47;
149
+ border-color: #1f5f47;
150
+ color: #ffffff;
151
+ }
152
+
153
+ .bb-world-label.is-enterable:hover::after,
154
+ .bb-world-label.is-enterable.is-hover::after {
155
+ content: ' 区画に入る ›';
156
+ color: #ffffff;
157
+ }
158
+
159
+ /* With nothing chosen, the rail lists the ways in (AC-19). */
160
+ .bb-world-entrances {
161
+ list-style: none;
162
+ margin: 0;
163
+ padding: 0;
164
+ display: grid;
165
+ gap: 8px;
166
+ }
167
+
168
+ .bb-world-entrance {
169
+ display: flex;
170
+ flex-wrap: wrap;
171
+ align-items: center;
172
+ gap: 4px 10px;
173
+ }
174
+
175
+ .bb-world-entrance-note {
176
+ font-size: 12px;
177
+ color: var(--bb-color-muted);
178
+ }
179
+
180
+ .bb-world-entrance-note.is-check {
181
+ color: #8a5a00;
182
+ font-weight: 600;
183
+ }
184
+
185
+ .bb-world-entrance-note.is-muted {
186
+ color: #6b6b6b;
187
+ }
188
+
135
189
 
136
190
  .bb-world-label.is-sector {
137
191
  background: transparent;
@@ -25,6 +25,7 @@
25
25
  import { THREE, MapControls } from './world-vendor.js';
26
26
  import { changesSince, cityMeasures, districtLots, groupJudgmentPlaces, skyAt, UNPLACED_REASON_TEXT } from './world-placement.js';
27
27
  import { createDistrictView } from './world-district.js';
28
+ import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
28
29
  import { WORK_STATES, workCityBlocks, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
29
30
  import {
30
31
  makeWorkspaceElement as el,
@@ -74,15 +75,6 @@ const statusText = (status) => (status ? statusEntry(status)?.label ?? status :
74
75
  const PLAZA_RADIUS = 7;
75
76
  const CITY_RING = 30;
76
77
 
77
- function hashUnit(text) {
78
- let h = 2166136261;
79
- for (let i = 0; i < text.length; i += 1) {
80
- h ^= text.charCodeAt(i);
81
- h = Math.imul(h, 16777619);
82
- }
83
- return ((h >>> 0) % 10000) / 10000;
84
- }
85
-
86
78
  async function readJson(fetcher, path) {
87
79
  try {
88
80
  const response = await fetcher(path);
@@ -189,199 +181,6 @@ const GAP_SHORT_TEXT = Object.freeze({
189
181
  review_overdue: '見直し超過',
190
182
  });
191
183
 
192
- function canvasTexture(doc, width, height, draw) {
193
- const canvas = doc.createElement('canvas');
194
- canvas.width = width;
195
- canvas.height = height;
196
- draw(canvas.getContext('2d'), width, height);
197
- const texture = new THREE.CanvasTexture(canvas);
198
- texture.colorSpace = THREE.SRGBColorSpace;
199
- return texture;
200
- }
201
-
202
- /** Smooth value noise in [0, 1]; used only to shade scenery (ground, sea, mountains) without repeating textures. */
203
- function valueNoise(x, z, seed = 'n') {
204
- const xi = Math.floor(x);
205
- const zi = Math.floor(z);
206
- const fx = x - xi;
207
- const fz = z - zi;
208
- const sx = fx * fx * (3 - 2 * fx);
209
- const sz = fz * fz * (3 - 2 * fz);
210
- const at = (i, j) => hashUnit(`${seed}${i},${j}`);
211
- const near = at(xi, zi) + (at(xi + 1, zi) - at(xi, zi)) * sx;
212
- const far = at(xi, zi + 1) + (at(xi + 1, zi + 1) - at(xi, zi + 1)) * sx;
213
- return near + (far - near) * sz;
214
- }
215
-
216
- /** Colours a geometry per vertex from its position (x, y, z before any rotation). */
217
- function paintVertices(geometry, colorAt) {
218
- const position = geometry.attributes.position;
219
- const colors = new Float32Array(position.count * 3);
220
- const color = new THREE.Color();
221
- for (let i = 0; i < position.count; i += 1) {
222
- colorAt(color, position.getX(i), position.getY(i), position.getZ(i));
223
- colors[i * 3] = color.r;
224
- colors[i * 3 + 1] = color.g;
225
- colors[i * 3 + 2] = color.b;
226
- }
227
- geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
228
- return geometry;
229
- }
230
-
231
- /*
232
- * Facades: one tile of a wall, drawn for a style and a wall colour. Windows are on the walls only (roofs
233
- * get a plain material). Which panes are lit is fixed per pane, so the glow map matches the colour map;
234
- * whether a building has lit windows at all still means "moved in the last 30 days".
235
- */
236
- const FACADE_UNITS = Object.freeze({ glass: [2.0, 2.3], office: [3.0, 2.0], brick: [1.3, 1.35], civic: [0.85, 1.5], research: [0.9, 1.2], house: [0, 0] });
237
-
238
- /** Tile size in pixels: glass and office tiles hold several floors so lit panes do not repeat as a pattern. */
239
- const FACADE_SIZES = Object.freeze({ glass: 256, office: 256 });
240
-
241
- function drawFacade(ctx, w, h, style, wall, lit, glowOnly) {
242
- const wallColor = `#${new THREE.Color(wall).getHexString()}`;
243
- const dark = `#${new THREE.Color(wall).multiplyScalar(0.72).getHexString()}`;
244
- const light = `#${new THREE.Color(wall).lerp(new THREE.Color(0xffffff), 0.35).getHexString()}`;
245
- ctx.fillStyle = glowOnly ? '#000000' : wallColor;
246
- ctx.fillRect(0, 0, w, h);
247
- const pane = (x, y, pw, ph, index) => {
248
- const on = lit && hashUnit(`${style}pane${index}x`) < 0.55;
249
- if (glowOnly) {
250
- ctx.fillStyle = on ? '#ffffff' : '#000000';
251
- ctx.fillRect(x, y, pw, ph);
252
- return;
253
- }
254
- if (on) {
255
- ctx.fillStyle = '#ffd98c';
256
- ctx.fillRect(x, y, pw, ph);
257
- ctx.fillStyle = 'rgba(255,244,214,0.55)';
258
- ctx.fillRect(x, y, pw, ph * 0.3);
259
- } else {
260
- const glass = ctx.createLinearGradient(x, y, x + pw, y + ph);
261
- glass.addColorStop(0, '#9fb3c4');
262
- glass.addColorStop(0.5, '#56687a');
263
- glass.addColorStop(1, '#3f4f60');
264
- ctx.fillStyle = glass;
265
- ctx.fillRect(x, y, pw, ph);
266
- }
267
- };
268
- if (style === 'glass') {
269
- // A curtain wall: four floors of four panes, thin mullions, a floor band under each floor.
270
- const cw = w / 4;
271
- const rh = h / 4;
272
- for (let r = 0; r < 4; r += 1) {
273
- if (!glowOnly) {
274
- ctx.fillStyle = dark;
275
- ctx.fillRect(0, r * rh + rh - 7, w, 7);
276
- }
277
- for (let c = 0; c < 4; c += 1) pane(c * cw + 4, r * rh + 5, cw - 8, rh - 15, r * 4 + c);
278
- }
279
- if (!glowOnly) {
280
- ctx.fillStyle = light;
281
- for (let c = 0; c <= 4; c += 1) ctx.fillRect(c * cw - 2, 0, 4, h);
282
- }
283
- } else if (style === 'office') {
284
- // Ribbon windows: two floors, each a long band split by mullions over a concrete spandrel.
285
- const rh = h / 2;
286
- for (let r = 0; r < 2; r += 1) {
287
- if (!glowOnly) {
288
- ctx.fillStyle = light;
289
- ctx.fillRect(0, r * rh, w, 22);
290
- ctx.fillStyle = dark;
291
- ctx.fillRect(0, r * rh + rh - 10, w, 6);
292
- }
293
- for (let c = 0; c < 8; c += 1) pane(4 + c * (w / 8), r * rh + 34, w / 8 - 6, rh - 54, r * 8 + c);
294
- }
295
- } else if (style === 'brick') {
296
- if (!glowOnly) {
297
- ctx.strokeStyle = 'rgba(0,0,0,0.07)';
298
- ctx.lineWidth = 2;
299
- for (let y = 16; y < h; y += 16) {
300
- ctx.beginPath();
301
- ctx.moveTo(0, y);
302
- ctx.lineTo(w, y);
303
- ctx.stroke();
304
- }
305
- }
306
- for (let c = 0; c < 2; c += 1) {
307
- const x = 18 + c * 58;
308
- if (!glowOnly) {
309
- ctx.fillStyle = '#f5f1e8';
310
- ctx.fillRect(x - 5, 22, 44, 82);
311
- }
312
- pane(x, 26, 34, 74, c);
313
- if (!glowOnly) {
314
- ctx.fillStyle = '#f5f1e8';
315
- ctx.fillRect(x + 15, 26, 4, 74);
316
- ctx.fillRect(x, 58, 34, 4);
317
- }
318
- }
319
- } else if (style === 'civic') {
320
- // Pilasters and tall windows.
321
- if (!glowOnly) {
322
- ctx.fillStyle = light;
323
- ctx.fillRect(0, 0, 18, h);
324
- ctx.fillRect(w - 18, 0, 18, h);
325
- }
326
- pane(30, 14, 68, 96, 0);
327
- if (!glowOnly) {
328
- ctx.fillStyle = light;
329
- ctx.fillRect(62, 14, 4, 96);
330
- }
331
- } else if (style === 'research') {
332
- for (let c = 0; c < 2; c += 1) pane(20 + c * 60, 18, 28, 90, c);
333
- } else if (style === 'house') {
334
- // A plastered wall with two framed windows (doors are separate meshes).
335
- for (let c = 0; c < 2; c += 1) {
336
- const x = 22 + c * 52;
337
- if (!glowOnly) {
338
- ctx.fillStyle = '#ffffff';
339
- ctx.fillRect(x - 4, 36, 40, 50);
340
- }
341
- pane(x, 40, 32, 42, c);
342
- if (!glowOnly) {
343
- ctx.fillStyle = '#ffffff';
344
- ctx.fillRect(x + 14, 40, 4, 42);
345
- ctx.fillStyle = dark;
346
- ctx.fillRect(x - 6, 86, 44, 5);
347
- }
348
- }
349
- }
350
- }
351
-
352
- function roundedPlate(width, depth, height, radius) {
353
- const shape = new THREE.Shape();
354
- const w = width / 2;
355
- const d = depth / 2;
356
- shape.moveTo(-w + radius, -d);
357
- shape.lineTo(w - radius, -d);
358
- shape.quadraticCurveTo(w, -d, w, -d + radius);
359
- shape.lineTo(w, d - radius);
360
- shape.quadraticCurveTo(w, d, w - radius, d);
361
- shape.lineTo(-w + radius, d);
362
- shape.quadraticCurveTo(-w, d, -w, d - radius);
363
- shape.lineTo(-w, -d + radius);
364
- shape.quadraticCurveTo(-w, -d, -w + radius, -d);
365
- const geometry = new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: true, bevelThickness: 0.12, bevelSize: 0.12, bevelSegments: 2 });
366
- geometry.rotateX(-Math.PI / 2);
367
- return geometry;
368
- }
369
-
370
- function gableRoof(width, depth, rise) {
371
- const shape = new THREE.Shape();
372
- shape.moveTo(-width / 2 - 0.1, 0);
373
- shape.lineTo(width / 2 + 0.1, 0);
374
- shape.lineTo(0, rise);
375
- shape.lineTo(-width / 2 - 0.1, 0);
376
- const geometry = new THREE.ExtrudeGeometry(shape, { depth: depth + 0.2, bevelEnabled: false });
377
- geometry.translate(0, 0, -(depth + 0.2) / 2);
378
- return geometry;
379
- }
380
-
381
- function muted(color, amount) {
382
- return new THREE.Color(color).lerp(new THREE.Color(0xb9c2bb), amount);
383
- }
384
-
385
184
  function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear, onEscape }) {
386
185
  const renderer = new THREE.WebGLRenderer({ antialias: true });
387
186
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
@@ -1097,6 +896,13 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1097
896
  cityIndex.set(business.code, { city, group, label: null });
1098
897
  const cityLabel = addLabel(statusPhase(business.status) === 'concept' ? `${business.name}(${statusText(business.status)})` : business.name, new THREE.Vector3(x, top, z), 'is-city', { priority: LABEL_PRIORITY.city, owner: group });
1099
898
  cityLabel.node.style.borderColor = `${entry.color}80`;
899
+ // The name is a way in too (AC-20): pressing it enters the district, as pressing the city does.
900
+ cityLabel.node.classList.add('is-enterable');
901
+ cityLabel.node.title = `${business.name}の区画に入る`;
902
+ cityLabel.node.addEventListener('click', () => {
903
+ select(group);
904
+ onPick(group.userData, new THREE.Box3().setFromObject(group).getCenter(new THREE.Vector3()));
905
+ });
1100
906
  cityIndex.get(business.code).label = cityLabel;
1101
907
  cityLabel.business = business;
1102
908
  const n = business.engagements.length;
@@ -1150,6 +956,12 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1150
956
  let selected = null;
1151
957
  let hovered = null;
1152
958
 
959
+ /** The name label of a city group, to say "enter" next to it while the pointer is on the city. */
960
+ function labelOfCity(group) {
961
+ if (group?.userData?.kind !== 'city') return null;
962
+ return cityIndex.get(group.userData.business.code)?.label ?? null;
963
+ }
964
+
1153
965
  function setHighlight(group, on) {
1154
966
  for (const entry of highlightable.get(group) ?? []) {
1155
967
  if (on) {
@@ -1204,6 +1016,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1204
1016
  });
1205
1017
  renderer.domElement.addEventListener('pointerleave', () => {
1206
1018
  pendingHover = null;
1019
+ labelOfCity(hovered)?.node.classList.remove('is-hover');
1207
1020
  if (hovered) setHighlight(hovered, false);
1208
1021
  hovered = null;
1209
1022
  });
@@ -1478,8 +1291,10 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1478
1291
  pendingHover = null;
1479
1292
  if (group !== hovered) {
1480
1293
  if (hovered) setHighlight(hovered, false);
1294
+ labelOfCity(hovered)?.node.classList.remove('is-hover');
1481
1295
  hovered = group;
1482
1296
  if (hovered) setHighlight(hovered, true);
1297
+ labelOfCity(hovered)?.node.classList.add('is-hover');
1483
1298
  renderer.domElement.style.cursor = hovered ? 'pointer' : '';
1484
1299
  }
1485
1300
  }
@@ -1634,6 +1449,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1634
1449
  skyIndex = (skyIndex + 1) % SKY_MODES.length;
1635
1450
  skyButton.textContent = SKY_MODES[skyIndex][1];
1636
1451
  scene?.setSky(SKY_MODES[skyIndex][0]);
1452
+ district?.setSky(SKY_MODES[skyIndex][0]);
1637
1453
  },
1638
1454
  });
1639
1455
  const header = workspacePageHeader(doc, {
@@ -1672,8 +1488,48 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1672
1488
  if (!rail) return;
1673
1489
  rail.replaceChildren(...nodes);
1674
1490
  }
1675
- const showEmptyRail = () => showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
1676
- showEmptyRail();
1491
+ /**
1492
+ * With nothing chosen, the rail lists the ways in (AC-19): one button per business, the ones with the
1493
+ * most work to check first. The count orders the list; it is not a score.
1494
+ */
1495
+ let workSummaries = {};
1496
+ const showEmptyRail = () => {
1497
+ if (!knownBusinesses.length) {
1498
+ showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
1499
+ return;
1500
+ }
1501
+ const noteOf = (business) => {
1502
+ const summary = workSummaries[business.code];
1503
+ if (!summary) return { text: workConnected === false ? '' : '仕事は区画で読みます', count: -1 };
1504
+ if (summary.state !== 'complete' && summary.state !== 'partial') return { text: '仕事を読めない(0件ではありません)', count: -1, muted: true };
1505
+ const needs = summary.needs_check?.length ?? 0;
1506
+ const parts = [needs ? `要確認 ${needs}` : `未完了 ${summary.open ?? 0}`];
1507
+ if (summary.state === 'partial') parts.push('一部だけ読めた');
1508
+ return { text: parts.join('・'), count: needs };
1509
+ };
1510
+ const rows = knownBusinesses
1511
+ .map((business) => ({ business, note: noteOf(business) }))
1512
+ .sort((a, b) => b.note.count - a.note.count || a.business.name.localeCompare(b.business.name, 'ja'));
1513
+ const list = el(doc, 'ul', { className: 'bb-world-entrances' });
1514
+ for (const { business, note } of rows) {
1515
+ const item = el(doc, 'li', { className: 'bb-world-entrance' });
1516
+ const button = workspaceButton(doc, { text: `${business.name}の区画に入る ›`, onClick: () => enterFromRail(business) });
1517
+ item.append(button);
1518
+ if (note.text) item.append(el(doc, 'span', { className: `bb-world-entrance-note${note.count > 0 ? ' is-check' : ''}${note.muted ? ' is-muted' : ''}`, text: note.text }));
1519
+ list.append(item);
1520
+ }
1521
+ showRail([
1522
+ workspaceRailHead(doc, { kicker: '区画の入口', title: 'どの区画に入りますか', lead: '下のボタン、または地図の都市(建物や名前)を押すと、その事業の区画に入ります。Escで一つ上に戻ります。' }),
1523
+ workspaceRailBlock(doc, { title: '事業', content: list }),
1524
+ ]);
1525
+ };
1526
+ // The businesses are not known yet here (the list is drawn again once the world is built).
1527
+ showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
1528
+
1529
+ function enterFromRail(business) {
1530
+ const picked = scene?.selectKey({ code: business.code });
1531
+ openCity(business, picked?.center ?? null);
1532
+ }
1677
1533
 
1678
1534
  let proofs = new Map();
1679
1535
  let placement = { byBusiness: new Map(), unplaced: [] };
@@ -1866,6 +1722,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
1866
1722
  },
1867
1723
  onEscape: goUp,
1868
1724
  });
1725
+ district.setSky(SKY_MODES[skyIndex][0]);
1869
1726
  scene.setPaused(true);
1870
1727
  legend.hidden = true;
1871
1728
  workLegend.hidden = true;
@@ -2012,6 +1869,8 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2012
1869
  }
2013
1870
  workConnected = true;
2014
1871
  const summaries = result.data.businesses ?? {};
1872
+ workSummaries = summaries;
1873
+ if (current.level === 'world' && !current.code) showEmptyRail();
2015
1874
  scene?.setWorkSigns(summaries);
2016
1875
  const entries = Object.values(summaries);
2017
1876
  const readable = entries.filter((entry) => entry.state === 'complete' || entry.state === 'partial');
@@ -2059,7 +1918,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2059
1918
  workspaceRailHead(doc, { kicker: `${business.name} の案件`, title: data.engagement.name, lead: data.engagement.summary ?? undefined }),
2060
1919
  workspaceRailBlock(doc, { title: '登録', content: workspaceDefinition(doc, [['状態', statusText(data.engagement.status)], ['Graph ID', data.engagement.id]]) }),
2061
1920
  detailBlock(data.engagement, '「プロジェクトと関係者」でこの案件を開く'),
2062
- workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: `${business.name}の仕事と関係を見る`, onClick: () => openCity(business, position) }) }),
1921
+ workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: `${business.name}の区画に入る`, variant: 'primary', onClick: () => openCity(business, position) }) }),
2063
1922
  cityJudgmentBlock(business),
2064
1923
  ]);
2065
1924
  return;
@@ -2209,6 +2068,7 @@ export function createWorldView({ root, rail, page, document: explicitDocument,
2209
2068
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
2210
2069
  showChangesSinceLastVisit(note, businesses, rows);
2211
2070
  knownBusinesses = businesses;
2071
+ if (!current.code) showEmptyRail();
2212
2072
  restoreSelection();
2213
2073
  void loadWorkSummary(note);
2214
2074
  }