Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Features

* Add `Highlighter.isolation` for selection isolation with adjustable context opacity, streamed-material support, and restoration of original material settings.

## [3.4.0](https://github.com/ThatOpen/engine_components/compare/v3.3.2...v3.4.0) (2026-04-09)


Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"Pablo Aguilar (https://github.com/pabloaguilarv"
],
"scripts": {
"test:isolation": "node tests/run-isolation.mjs",
"dev": "vite --host",
"build-core": "yarn workspace @thatopen/components build",
"build-front": "yarn workspace @thatopen/components-front build",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Selection context opacity</title>
<style>
body {
margin: 0;
background: #171b22;
color: #eef2f7;
font: 14px system-ui;
}
#container {
width: 100vw;
height: 100vh;
}
aside {
position: fixed;
top: 16px;
right: 16px;
width: min(300px, calc(100vw - 64px));
padding: 16px;
background: #242b35;
border: 1px solid #526176;
border-radius: 8px;
}
h1 {
margin: 0 0 12px;
font-size: 18px;
}
button {
padding: 8px 12px;
margin: 4px 0;
background: #38475b;
color: inherit;
border: 1px solid #75879d;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
label {
display: block;
margin-top: 12px;
}
input {
width: 100%;
accent-color: #bcf124;
}
#status {
display: block;
margin-top: 12px;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<div id="container"></div>
<aside>
<h1>Selection context opacity</h1>
<p>
Click an element, or select a sample. Ctrl + click adds to the
selection.
</p>
<button id="sample" disabled>Select sample</button>
<button id="isolate" disabled>Isolate selection</button>
<label for="opacity"
>Context opacity: <output id="value">15%</output></label
>
<input
id="opacity"
type="range"
min="0"
max="100"
step="1"
value="15"
disabled
/>
<button id="reset" disabled>Show all</button>
<button id="clear" disabled>Clear selection</button>
<output id="status" role="status">Loading public sample…</output>
</aside>
<script type="module" src="./example.ts"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* MD
## Selection with translucent context
Isolate highlighted items while retaining the surrounding model at a chosen opacity.
`highlighter.isolation.isolate(0.15)` shows context at 15% of its original opacity;
`setOpacity(0)` hides it and `reset()` restores original materials and shows all items.
Isolation follows the select style, including Ctrl-click changes. Clearing selection
exits isolation. Configure a non-null select material so selected items remain distinct.
Translucent isolation rejects a selection covered by a higher-priority style; clear
the overlapping items or adjust that style's priority explicitly before isolating.
If such a conflict appears while isolation is active, isolation resets and reports
the error. Style memberships and priorities are preserved.
Disposal cancels pending isolation updates and restores materials synchronously.
If retaining the scene, await `isolation.reset()` before disposing the Highlighter
to restore item visibility as well.
Call `isolation.refresh()` after loading more models while isolation is active.

The component owns item visibility while active; reset shows all items, as Hider does.
Positive slider changes modify existing graphics materials without enumerating model IDs.
Streamed materials inherit the same factor. Original glass transparency is multiplied,
not replaced. Independent styles retain their colors and participate in the context.
*/
import * as THREE from "three";
import * as OBC from "@thatopen/components";
import { Highlighter } from "../../index";

const container = document.getElementById("container")!;
const status = document.getElementById("status")!;
const slider = document.getElementById("opacity") as HTMLInputElement;
const value = document.getElementById("value")!;
const components = new OBC.Components();
const world = components
.get(OBC.Worlds)
.create<OBC.SimpleScene, OBC.OrthoPerspectiveCamera, OBC.SimpleRenderer>();
world.scene = new OBC.SimpleScene(components);
world.scene.setup();
world.scene.three.background = new THREE.Color("#171b22");
world.renderer = new OBC.SimpleRenderer(components, container);
world.camera = new OBC.OrthoPerspectiveCamera(components);
await world.camera.controls.setLookAt(68, 23, -8.5, 21.5, -5.5, 23);
components.init();
const fragments = components.get(OBC.FragmentsManager);
const highlighter = components.get(Highlighter);
const report = (error: unknown) => {
status.textContent = String(error);
};
const update = () => fragments.core.update().catch(report);

try {
const worker = await fetch(
"https://thatopen.github.io/engine_fragment/resources/worker.mjs",
);
if (!worker.ok) throw new Error(`Worker download failed: ${worker.status}`);
const workerUrl = URL.createObjectURL(
new Blob([await worker.text()], { type: "text/javascript" }),
);
fragments.init(workerUrl);
world.camera.controls.addEventListener("update", update);
fragments.list.onItemSet.add(({ value: model }) => {
model.useCamera(world.camera.three);
world.scene.three.add(model.object);
});
const file = await fetch(
"https://thatopen.github.io/engine_components/resources/frags/school_arq.frag",
);
if (!file.ok) throw new Error(`Model download failed: ${file.status}`);
const model = await fragments.core.load(await file.arrayBuffer(), {
modelId: "school",
});
highlighter.setup({ world });
await fragments.core.update(true);
const ids = await model.getItemsIdsWithGeometry();
const run = async (operation: () => Promise<unknown>) => {
try {
await operation();
status.textContent = highlighter.isolation.active
? `Isolated · context ${Math.round(highlighter.isolation.opacity * 100)}%`
: "All items visible";
} catch (error) {
report(error);
}
};
document.getElementById("sample")!.onclick = () =>
run(() =>
highlighter.highlightByID("select", { school: new Set(ids.slice(0, 3)) }),
);
document.getElementById("isolate")!.onclick = () =>
run(() => highlighter.isolation.isolate(Number(slider.value) / 100));
document.getElementById("reset")!.onclick = () =>
run(() => highlighter.isolation.reset());
document.getElementById("clear")!.onclick = () =>
run(() => highlighter.clear("select"));
slider.oninput = () => {
value.textContent = `${slider.value}%`;
void run(() =>
highlighter.isolation.setOpacity(Number(slider.value) / 100),
);
};
for (const control of document.querySelectorAll<
HTMLButtonElement | HTMLInputElement
>("button, input"))
control.disabled = false;
status.textContent = `Ready · ${ids.length} geometric items`;
} catch (error) {
report(error);
}
35 changes: 35 additions & 0 deletions packages/front/src/fragments/Highlighter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as OBC from "@thatopen/components";
import { DataMap } from "@thatopen/fragments";
import * as FRAGS from "@thatopen/fragments";
import { HighlighterConfig, HighlightEvents, HighlightStyle } from "./src";
import { HighlighterIsolation } from "./src/isolation";

/**
* This component allows highlighting and selecting fragments in a 3D scene. 📕 [Tutorial](https://docs.thatopen.com/Tutorials/Components/Front/Highlighter). 📘 [API](https://docs.thatopen.com/api/@thatopen/components-front/classes/Highlighter).
Expand Down Expand Up @@ -36,6 +37,9 @@ export class Highlighter
/** {@link OBC.Component.enabled} */
enabled = true;

/** Isolates the select style with adjustable context opacity from 0 (hidden) to 1. */
readonly isolation: HighlighterIsolation;

/** Stores the events triggered by the Highlighter. */
events: HighlightEvents = {};

Expand Down Expand Up @@ -113,6 +117,12 @@ export class Highlighter

constructor(components: OBC.Components) {
super(components);
this.isolation = new HighlighterIsolation(
() => this.components.get(OBC.FragmentsManager).core,
() => this.selection[this.config.selectName] ?? {},
() => this.styles.get(this.config.selectName) ? this.config.selectName : null,
(selection) => this.validateIsolationSelection(selection),
);
this.components.add(Highlighter.uuid, this);
this.eventManager.list.add(this.onSetup);
this.eventManager.list.add(this.onDisposed);
Expand Down Expand Up @@ -147,6 +157,7 @@ export class Highlighter

/** {@link Disposable.dispose} */
async dispose() {
this.isolation.dispose();
this.setupEvents(false);
this.onBeforeUpdate.reset();
this.onAfterUpdate.reset();
Expand Down Expand Up @@ -402,6 +413,11 @@ export class Highlighter
// priority is ours, not part of the material definition fragments expects.
const { priority: _priority, ...material } = definition;

// Keep the rendered style identity so selection stays opaque while context fades.
if (material.preserveOriginalMaterial) {
material._explicitProps = [...new Set([...(material._explicitProps ?? []), "customId"])];
}

promises.push(
fragments.highlight({ ...material, customId: style }, map),
);
Expand All @@ -411,6 +427,7 @@ export class Highlighter
promises.push(fragments.core.update(true));
}
await Promise.allSettled(promises);
await this.isolation.refresh();
}

/**
Expand All @@ -427,6 +444,24 @@ export class Highlighter
});
}

private validateIsolationSelection(selection: OBC.ModelIdMap) {
for (const style of this.getStylesByPriority()) {
if (style === this.config.selectName) return;
if (!this.styles.get(style)) continue;
for (const [modelId, ids] of Object.entries(selection)) {
const other = this.selection[style]?.[modelId];
if (!other) continue;
const smaller = ids.size < other.size ? ids : other;
const larger = smaller === ids ? other : ids;
for (const id of smaller) {
if (larger.has(id)) {
throw new Error(`Cannot fade context: style "${style}" has priority over selected items. Clear its overlapping items or change its priority first.`);
}
}
}
}
}

private getPriority(style: string) {
const priority = this.styles.get(style)?.priority;
if (priority !== undefined) return priority;
Expand Down
1 change: 1 addition & 0 deletions packages/front/src/fragments/Highlighter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./types";
export * from "./isolation";
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { FragmentsModels } from "@thatopen/fragments";
import type { Material } from "three";

type Materials = FragmentsModels["models"]["materials"]["list"];
type Original = Pick<Material, "opacity" | "transparent" | "depthWrite">;

export class IsolationMaterials {
private originals = new WeakMap<Material, Original>();
private opacity = 1;

constructor(
private materials: Materials,
private getStyle: () => string | null,
) {}

setOpacity(opacity: number) {
this.opacity = opacity;
this.materials.onItemSet.remove(this.update);
if (opacity < 1) this.materials.onItemSet.add(this.update);
for (const value of this.materials.values()) this.update({ value });
}

dispose() {
this.setOpacity(1);
}

private update = ({ value }: { value: Material }) => {
const opacity =
value.userData.customId === this.getStyle() ? 1 : this.opacity;
let original = this.originals.get(value);
if (!original) {
if (opacity === 1) return;
original = {
opacity: value.opacity,
transparent: value.transparent,
depthWrite: value.depthWrite,
};
this.originals.set(value, original);
}
const transparent = opacity < 1 || original.transparent;
if (value.transparent !== transparent) value.needsUpdate = true;
value.opacity = original.opacity * opacity;
value.transparent = transparent;
value.depthWrite = opacity < 1 ? false : original.depthWrite;
if (opacity === 1) this.originals.delete(value);
};
}
Loading