@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.
- package/dist/world-extension.js +1 -1
- package/package.json +2 -2
- package/ui/world/world-district.js +306 -87
- package/ui/world/world-scenery.js +208 -0
- package/ui/world/world-view.css +54 -0
- package/ui/world/world-view.js +65 -205
package/dist/world-extension.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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
|
-
|
|
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,
|
|
90
|
-
gradient.addColorStop(0.
|
|
91
|
-
gradient.addColorStop(1,
|
|
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(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
sun.
|
|
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
|
-
|
|
102
|
-
sun.shadow.bias = -0.
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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,
|
|
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
|
-
|
|
194
|
-
group.add(mesh(new THREE.BoxGeometry(
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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.
|
|
206
|
-
group.add(mesh(new THREE.BoxGeometry(0.2, 0.5, 0.2), material(0x9c7a62), { x: 0.
|
|
207
|
-
group.userData.chimney = new THREE.Vector3(0.
|
|
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
|
-
|
|
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
|
|
288
|
-
const
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
root.add(
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
};
|
|
300
|
-
|
|
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
|
-
|
|
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.
|
|
313
|
-
root.add(mesh(new THREE.BoxGeometry(1.
|
|
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
|
|
318
|
-
const
|
|
319
|
-
const
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
root.add(mesh(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
343
|
-
const hallChimney = new THREE.Vector3(2.8,
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/ui/world/world-view.css
CHANGED
|
@@ -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;
|
package/ui/world/world-view.js
CHANGED
|
@@ -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
|
-
|
|
1676
|
-
|
|
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}
|
|
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
|
}
|